Merge branch 'master' into contact
Conflicts: static/js/controllers.js templates/base.html
11
README.md
|
@ -1,27 +1,26 @@
|
|||
to prepare a new host:
|
||||
|
||||
```
|
||||
sudo apt-get install software-properties-common
|
||||
sudo apt-add-repository -y ppa:nginx/stable
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y git python-virtualenv python-dev phantomjs libjpeg8 libjpeg62-dev libfreetype6 libfreetype6-dev libevent-dev
|
||||
sudo apt-get install -y nginx-full
|
||||
sudo apt-get install -y git python-virtualenv python-dev phantomjs libjpeg8 libjpeg62-dev libfreetype6 libfreetype6-dev libevent-dev gdebi-core
|
||||
```
|
||||
|
||||
check out the code:
|
||||
|
||||
```
|
||||
git clone https://bitbucket.org/yackob03/quay.git
|
||||
cd quay
|
||||
virtualenv --distribute venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
sudo gdebi --n binary_dependencies/*.deb
|
||||
```
|
||||
|
||||
running:
|
||||
|
||||
```
|
||||
sudo nginx -c `pwd`/nginx.conf
|
||||
STACK=prod gunicorn -D --workers 4 -b unix:/tmp/gunicorn.sock --worker-class gevent -t 2000 application:application
|
||||
sudo mkdir -p /mnt/nginx/ && sudo /usr/local/nginx/sbin/nginx -c `pwd`/nginx.conf
|
||||
STACK=prod gunicorn -c gunicorn_config.py application:application
|
||||
```
|
||||
|
||||
start the workers:
|
||||
|
|
|
@ -34,7 +34,7 @@ def process_basic_auth(auth):
|
|||
if len(credentials) != 2:
|
||||
logger.debug('Invalid basic auth credential format.')
|
||||
|
||||
if credentials[0] == '$token':
|
||||
elif credentials[0] == '$token':
|
||||
# Use as token auth
|
||||
try:
|
||||
token = model.load_token_data(credentials[1])
|
||||
|
@ -77,7 +77,6 @@ def process_basic_auth(auth):
|
|||
|
||||
# We weren't able to authenticate via basic auth.
|
||||
logger.debug('Basic auth present but could not be validated.')
|
||||
abort(401)
|
||||
|
||||
|
||||
def process_token(auth):
|
||||
|
|
BIN
binary_dependencies/nginx_1.4.2-nobuffer-1_amd64.deb
Normal file
|
@ -22,5 +22,5 @@ RUN venv/bin/pip install -r requirements.txt
|
|||
|
||||
VOLUME /var/lib/docker
|
||||
|
||||
EXPOSE 5002:5002
|
||||
EXPOSE 5002
|
||||
CMD startserver
|
|
@ -6,10 +6,9 @@ import re
|
|||
import requests
|
||||
import json
|
||||
|
||||
from flask import Flask, jsonify, url_for, abort, make_response
|
||||
from flask import Flask, jsonify, abort, make_response
|
||||
from zipfile import ZipFile
|
||||
from tempfile import TemporaryFile, mkdtemp
|
||||
from uuid import uuid4
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from base64 import b64encode
|
||||
|
||||
|
@ -53,16 +52,24 @@ def prepare_dockerfile(request_file):
|
|||
return build_dir
|
||||
|
||||
|
||||
def total_completion(statuses, total_images):
|
||||
percentage_with_sizes = float(len(statuses.values()))/total_images
|
||||
sent_bytes = sum([status[u'current'] for status in statuses.values()])
|
||||
total_bytes = sum([status[u'total'] for status in statuses.values()])
|
||||
return float(sent_bytes)/total_bytes*percentage_with_sizes
|
||||
|
||||
|
||||
def build_image(build_dir, tag_name, num_steps, result_object):
|
||||
try:
|
||||
logger.debug('Starting build.')
|
||||
docker_cl = docker.Client(version='1.5')
|
||||
docker_cl = docker.Client(timeout=1200)
|
||||
result_object['status'] = 'building'
|
||||
build_status = docker_cl.build(path=build_dir, tag=tag_name)
|
||||
build_status = docker_cl.build(path=build_dir, tag=tag_name, stream=True)
|
||||
|
||||
current_step = 0
|
||||
built_image = None
|
||||
for status in build_status:
|
||||
# logger.debug('Status: %s', str(status))
|
||||
step_increment = re.search(r'Step ([0-9]+) :', status)
|
||||
if step_increment:
|
||||
current_step = int(step_increment.group(1))
|
||||
|
@ -84,40 +91,36 @@ def build_image(build_dir, tag_name, num_steps, result_object):
|
|||
result_object['message'] = 'Unable to build dockerfile.'
|
||||
return
|
||||
|
||||
history = docker_cl.history(built_image)
|
||||
history = json.loads(docker_cl.history(built_image))
|
||||
num_images = len(history)
|
||||
result_object['total_images'] = num_images
|
||||
|
||||
result_object['status'] = 'pushing'
|
||||
logger.debug('Pushing to tag name: %s' % tag_name)
|
||||
resp = docker_cl.push(tag_name)
|
||||
resp = docker_cl.push(tag_name, stream=True)
|
||||
|
||||
current_image = 0
|
||||
image_progress = 0
|
||||
for status in resp:
|
||||
for status_str in resp:
|
||||
status = json.loads(status_str)
|
||||
logger.debug('Status: %s', status_str)
|
||||
if u'status' in status:
|
||||
status_msg = status[u'status']
|
||||
next_image = r'(Pushing|Image) [a-z0-9]+( already pushed, skipping)?$'
|
||||
match = re.match(next_image, status_msg)
|
||||
if match:
|
||||
current_image += 1
|
||||
image_progress = 0
|
||||
logger.debug('Now pushing image %s/%s' %
|
||||
(current_image, num_images))
|
||||
|
||||
elif status_msg == u'Pushing' and u'progress' in status:
|
||||
percent = r'\(([0-9]+)%\)'
|
||||
match = re.search(percent, status[u'progress'])
|
||||
if match:
|
||||
image_progress = int(match.group(1))
|
||||
if status_msg == 'Pushing':
|
||||
if u'progressDetail' in status and u'id' in status:
|
||||
image_id = status[u'id']
|
||||
detail = status[u'progressDetail']
|
||||
|
||||
result_object['current_image'] = current_image
|
||||
result_object['image_completion_percent'] = image_progress
|
||||
if u'current' in detail and 'total' in detail:
|
||||
images = result_object['image_completion']
|
||||
|
||||
images[image_id] = detail
|
||||
result_object['push_completion'] = total_completion(images,
|
||||
num_images)
|
||||
|
||||
elif u'errorDetail' in status:
|
||||
result_object['status'] = 'error'
|
||||
if u'message' in status[u'errorDetail']:
|
||||
result_object['message'] = status[u'errorDetail'][u'message']
|
||||
result_object['message'] = str(status[u'errorDetail'][u'message'])
|
||||
return
|
||||
|
||||
result_object['status'] = 'complete'
|
||||
|
@ -133,15 +136,14 @@ MIME_PROCESSORS = {
|
|||
'application/octet-stream': prepare_dockerfile,
|
||||
}
|
||||
|
||||
|
||||
# If this format it should also be changed in the api method get_repo_builds
|
||||
build = {
|
||||
'total_commands': None,
|
||||
'total_images': None,
|
||||
'current_command': None,
|
||||
'current_image': None,
|
||||
'image_completion_percent': None,
|
||||
'push_completion': 0.0,
|
||||
'status': 'waiting',
|
||||
'message': None,
|
||||
'image_completion': {},
|
||||
}
|
||||
pool = ThreadPool(1)
|
||||
|
||||
|
|
|
@ -127,6 +127,7 @@ class DigitalOceanConfig(object):
|
|||
DO_SSH_KEY_ID = '46986'
|
||||
DO_SSH_PRIVATE_KEY_FILENAME = 'certs/digital_ocean'
|
||||
DO_ALLOWED_REGIONS = {1, 4}
|
||||
DO_DOCKER_IMAGE = 1341147
|
||||
|
||||
|
||||
class BuildNodeConfig(object):
|
||||
|
|
|
@ -167,7 +167,9 @@ class Image(BaseModel):
|
|||
checksum = CharField(null=True)
|
||||
created = DateTimeField(null=True)
|
||||
comment = TextField(null=True)
|
||||
command = TextField(null=True)
|
||||
repository = ForeignKeyField(Repository)
|
||||
image_size = BigIntegerField(null=True)
|
||||
|
||||
# '/' separated list of ancestory ids, e.g. /1/2/6/7/10/
|
||||
ancestors = CharField(index=True, default='/', max_length=64535)
|
||||
|
|
141
data/model.py
|
@ -6,12 +6,14 @@ import operator
|
|||
import json
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from database import *
|
||||
from util.validation import *
|
||||
from util.names import format_robot_username
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
store = app.config['STORAGE']
|
||||
|
||||
|
||||
class DataModelException(Exception):
|
||||
|
@ -306,12 +308,18 @@ def create_federated_user(username, email, service_name, service_id):
|
|||
return new_user
|
||||
|
||||
|
||||
def attach_federated_login(user, service_name, service_id):
|
||||
service = LoginService.get(LoginService.name == service_name)
|
||||
FederatedLogin.create(user=user, service=service, service_ident=service_id)
|
||||
return user
|
||||
|
||||
|
||||
def verify_federated_login(service_name, service_id):
|
||||
selected = FederatedLogin.select(FederatedLogin, User)
|
||||
with_service = selected.join(LoginService)
|
||||
with_user = with_service.switch(FederatedLogin).join(User)
|
||||
found = with_user.where(FederatedLogin.service_ident == service_id,
|
||||
LoginService.name == service_name)
|
||||
LoginService.name == service_name)
|
||||
|
||||
found_list = list(found)
|
||||
|
||||
|
@ -321,14 +329,25 @@ def verify_federated_login(service_name, service_id):
|
|||
return None
|
||||
|
||||
|
||||
def list_federated_logins(user):
|
||||
selected = FederatedLogin.select(FederatedLogin.service_ident,
|
||||
LoginService.name)
|
||||
joined = selected.join(LoginService)
|
||||
return joined.where(LoginService.name != 'quayrobot',
|
||||
FederatedLogin.user == user)
|
||||
|
||||
|
||||
def create_confirm_email_code(user):
|
||||
code = EmailConfirmation.create(user=user, email_confirm=True)
|
||||
return code
|
||||
|
||||
|
||||
def confirm_user_email(code):
|
||||
code = EmailConfirmation.get(EmailConfirmation.code == code,
|
||||
EmailConfirmation.email_confirm == True)
|
||||
try:
|
||||
code = EmailConfirmation.get(EmailConfirmation.code == code,
|
||||
EmailConfirmation.email_confirm == True)
|
||||
except EmailConfirmation.DoesNotExist:
|
||||
raise DataModelException('Invalid email confirmation code.')
|
||||
|
||||
user = code.user
|
||||
user.verified = True
|
||||
|
@ -345,6 +364,9 @@ def create_reset_password_email_code(email):
|
|||
except User.DoesNotExist:
|
||||
raise InvalidEmailAddressException('Email address was not found.');
|
||||
|
||||
if user.organization:
|
||||
raise InvalidEmailAddressException('Organizations can not have passwords.')
|
||||
|
||||
code = EmailConfirmation.create(user=user, pw_reset=True)
|
||||
return code
|
||||
|
||||
|
@ -383,7 +405,6 @@ def get_matching_teams(team_prefix, organization):
|
|||
|
||||
def get_matching_users(username_prefix, robot_namespace=None,
|
||||
organization=None):
|
||||
Org = User.alias()
|
||||
direct_user_query = (User.username ** (username_prefix + '%') &
|
||||
(User.organization == False) & (User.robot == False))
|
||||
|
||||
|
@ -393,14 +414,16 @@ def get_matching_users(username_prefix, robot_namespace=None,
|
|||
(User.username ** (robot_prefix + '%') &
|
||||
(User.robot == True)))
|
||||
|
||||
query = User.select(User.username, Org.username, User.robot).where(direct_user_query)
|
||||
query = (User
|
||||
.select(User.username, fn.Sum(Team.id), User.robot)
|
||||
.group_by(User.username)
|
||||
.where(direct_user_query))
|
||||
|
||||
if organization:
|
||||
with_team = query.join(TeamMember, JOIN_LEFT_OUTER).join(Team,
|
||||
JOIN_LEFT_OUTER)
|
||||
with_org = with_team.join(Org, JOIN_LEFT_OUTER,
|
||||
on=(Org.id == Team.organization))
|
||||
query = with_org.where((Org.id == organization) | (Org.id >> None))
|
||||
query = (query
|
||||
.join(TeamMember, JOIN_LEFT_OUTER)
|
||||
.join(Team, JOIN_LEFT_OUTER, on=((Team.id == TeamMember.team) &
|
||||
(Team.organization == organization))))
|
||||
|
||||
|
||||
class MatchingUserResult(object):
|
||||
|
@ -408,7 +431,7 @@ def get_matching_users(username_prefix, robot_namespace=None,
|
|||
self.username = args[0]
|
||||
self.is_robot = args[2]
|
||||
if organization:
|
||||
self.is_org_member = (args[1] == organization.username)
|
||||
self.is_org_member = (args[1] != None)
|
||||
else:
|
||||
self.is_org_member = None
|
||||
|
||||
|
@ -489,13 +512,23 @@ def get_user_teams_within_org(username, organization):
|
|||
User.username == username)
|
||||
|
||||
|
||||
def get_visible_repositories(username=None, include_public=True, limit=None,
|
||||
def get_visible_repository_count(username=None, include_public=True, sort=False, namespace=None):
|
||||
return get_visible_repository_internal(username=username, include_public=include_public,
|
||||
sort=sort, namespace=namespace, get_count=True)
|
||||
|
||||
def get_visible_repositories(username=None, include_public=True, page=None, limit=None,
|
||||
sort=False, namespace=None):
|
||||
return get_visible_repository_internal(username=username, include_public=include_public, page=page,
|
||||
limit=limit, sort=sort, namespace=namespace, get_count=False)
|
||||
|
||||
|
||||
def get_visible_repository_internal(username=None, include_public=True, limit=None, page=None,
|
||||
sort=False, namespace=None, get_count=False):
|
||||
if not username and not include_public:
|
||||
return []
|
||||
|
||||
query = (Repository
|
||||
.select(Repository, Visibility)
|
||||
.select() # Note: We need to leave this blank for the get_count case. Otherwise, MySQL/RDS complains.
|
||||
.distinct()
|
||||
.join(Visibility)
|
||||
.switch(Repository)
|
||||
|
@ -543,10 +576,19 @@ def get_visible_repositories(username=None, include_public=True, limit=None,
|
|||
else:
|
||||
where_clause = new_clause
|
||||
|
||||
if limit:
|
||||
query.limit(limit)
|
||||
if sort:
|
||||
query = query.order_by(Repository.description.desc())
|
||||
|
||||
return query.where(where_clause)
|
||||
if page:
|
||||
query = query.paginate(page, limit)
|
||||
elif limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
where = query.where(where_clause)
|
||||
if get_count:
|
||||
return where.count()
|
||||
else:
|
||||
return where
|
||||
|
||||
|
||||
def get_matching_repositories(repo_term, username=None):
|
||||
|
@ -702,8 +744,23 @@ def set_image_checksum(docker_image_id, repository, checksum):
|
|||
return fetched
|
||||
|
||||
|
||||
def set_image_size(docker_image_id, namespace_name, repository_name, image_size):
|
||||
joined = Image.select().join(Repository)
|
||||
image_list = list(joined.where(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name,
|
||||
Image.docker_image_id == docker_image_id))
|
||||
|
||||
if not image_list:
|
||||
raise DataModelException('No image with specified id and repository')
|
||||
|
||||
fetched = image_list[0]
|
||||
fetched.image_size = image_size
|
||||
fetched.save()
|
||||
return fetched
|
||||
|
||||
|
||||
def set_image_metadata(docker_image_id, namespace_name, repository_name,
|
||||
created_date_str, comment, parent=None):
|
||||
created_date_str, comment, command, parent=None):
|
||||
joined = Image.select().join(Repository)
|
||||
image_list = list(joined.where(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name,
|
||||
|
@ -715,6 +772,7 @@ def set_image_metadata(docker_image_id, namespace_name, repository_name,
|
|||
fetched = image_list[0]
|
||||
fetched.created = dateutil.parser.parse(created_date_str)
|
||||
fetched.comment = comment
|
||||
fetched.command = command
|
||||
|
||||
if parent:
|
||||
fetched.ancestors = '%s%s/' % (parent.ancestors, parent.id)
|
||||
|
@ -736,6 +794,50 @@ def list_repository_tags(namespace_name, repository_name):
|
|||
return with_image.where(Repository.name == repository_name,
|
||||
Repository.namespace == namespace_name)
|
||||
|
||||
def delete_tag_and_images(namespace_name, repository_name, tag_name):
|
||||
all_images = get_repository_images(namespace_name, repository_name)
|
||||
all_tags = list_repository_tags(namespace_name, repository_name)
|
||||
|
||||
# Find the tag's information.
|
||||
found_tag = None
|
||||
for tag in all_tags:
|
||||
if tag.name == tag_name:
|
||||
found_tag = tag
|
||||
break
|
||||
|
||||
if not found_tag:
|
||||
return
|
||||
|
||||
# Build the set of database IDs corresponding to the tag's ancestor images,
|
||||
# as well as the tag's image itself.
|
||||
tag_image_ids = set(found_tag.image.ancestors.split('/'))
|
||||
tag_image_ids.add(str(found_tag.image.id))
|
||||
|
||||
# Filter out any images that belong to any other tags.
|
||||
for tag in all_tags:
|
||||
if tag.name != tag_name:
|
||||
# Remove all ancestors of the tag.
|
||||
tag_image_ids = tag_image_ids - set(tag.image.ancestors.split('/'))
|
||||
|
||||
# Remove the current image ID.
|
||||
tag_image_ids.discard(str(tag.image.id))
|
||||
|
||||
# Find all the images that belong to the tag.
|
||||
tag_images = [image for image in all_images
|
||||
if str(image.id) in tag_image_ids]
|
||||
|
||||
# Delete the tag found.
|
||||
found_tag.delete_instance()
|
||||
|
||||
# Delete the images found.
|
||||
for image in tag_images:
|
||||
image.delete_instance()
|
||||
|
||||
repository_path = store.image_path(namespace_name, repository_name,
|
||||
image.docker_image_id)
|
||||
logger.debug('Recursively deleting image path: %s' % repository_path)
|
||||
store.remove(repository_path)
|
||||
|
||||
|
||||
def get_tag_image(namespace_name, repository_name, tag_name):
|
||||
joined = Image.select().join(RepositoryTag).join(Repository)
|
||||
|
@ -933,6 +1035,11 @@ def purge_repository(namespace_name, repository_name):
|
|||
Repository.namespace == namespace_name)
|
||||
fetched.delete_instance(recursive=True)
|
||||
|
||||
repository_path = store.repository_namespace_path(namespace_name,
|
||||
repository_name)
|
||||
logger.debug('Recursively deleting path: %s' % repository_path)
|
||||
store.remove(repository_path)
|
||||
|
||||
|
||||
def get_private_repo_count(username):
|
||||
joined = Repository.select().join(Visibility)
|
||||
|
|
|
@ -1,20 +1,13 @@
|
|||
import json
|
||||
import itertools
|
||||
|
||||
USER_PLANS = [
|
||||
{
|
||||
'title': 'Open Source',
|
||||
'price': 0,
|
||||
'privateRepos': 0,
|
||||
'stripeId': 'free',
|
||||
'audience': 'Share with the world',
|
||||
},
|
||||
PLANS = [
|
||||
# Deprecated Plans
|
||||
{
|
||||
'title': 'Micro',
|
||||
'price': 700,
|
||||
'privateRepos': 5,
|
||||
'stripeId': 'micro',
|
||||
'audience': 'For smaller teams',
|
||||
'bus_features': False,
|
||||
'deprecated': True,
|
||||
},
|
||||
{
|
||||
'title': 'Basic',
|
||||
|
@ -22,6 +15,8 @@ USER_PLANS = [
|
|||
'privateRepos': 10,
|
||||
'stripeId': 'small',
|
||||
'audience': 'For your basic team',
|
||||
'bus_features': False,
|
||||
'deprecated': True,
|
||||
},
|
||||
{
|
||||
'title': 'Medium',
|
||||
|
@ -29,6 +24,8 @@ USER_PLANS = [
|
|||
'privateRepos': 20,
|
||||
'stripeId': 'medium',
|
||||
'audience': 'For medium teams',
|
||||
'bus_features': False,
|
||||
'deprecated': True,
|
||||
},
|
||||
{
|
||||
'title': 'Large',
|
||||
|
@ -36,16 +33,28 @@ USER_PLANS = [
|
|||
'privateRepos': 50,
|
||||
'stripeId': 'large',
|
||||
'audience': 'For larger teams',
|
||||
'bus_features': False,
|
||||
'deprecated': True,
|
||||
},
|
||||
]
|
||||
|
||||
BUSINESS_PLANS = [
|
||||
# Active plans
|
||||
{
|
||||
'title': 'Open Source',
|
||||
'price': 0,
|
||||
'privateRepos': 0,
|
||||
'stripeId': 'bus-free',
|
||||
'stripeId': 'free',
|
||||
'audience': 'Committment to FOSS',
|
||||
'bus_features': False,
|
||||
'deprecated': False,
|
||||
},
|
||||
{
|
||||
'title': 'Personal',
|
||||
'price': 1200,
|
||||
'privateRepos': 5,
|
||||
'stripeId': 'personal',
|
||||
'audience': 'Individuals',
|
||||
'bus_features': False,
|
||||
'deprecated': False,
|
||||
},
|
||||
{
|
||||
'title': 'Skiff',
|
||||
|
@ -53,6 +62,8 @@ BUSINESS_PLANS = [
|
|||
'privateRepos': 10,
|
||||
'stripeId': 'bus-micro',
|
||||
'audience': 'For startups',
|
||||
'bus_features': True,
|
||||
'deprecated': False,
|
||||
},
|
||||
{
|
||||
'title': 'Yacht',
|
||||
|
@ -60,6 +71,8 @@ BUSINESS_PLANS = [
|
|||
'privateRepos': 20,
|
||||
'stripeId': 'bus-small',
|
||||
'audience': 'For small businesses',
|
||||
'bus_features': True,
|
||||
'deprecated': False,
|
||||
},
|
||||
{
|
||||
'title': 'Freighter',
|
||||
|
@ -67,6 +80,8 @@ BUSINESS_PLANS = [
|
|||
'privateRepos': 50,
|
||||
'stripeId': 'bus-medium',
|
||||
'audience': 'For normal businesses',
|
||||
'bus_features': True,
|
||||
'deprecated': False,
|
||||
},
|
||||
{
|
||||
'title': 'Tanker',
|
||||
|
@ -74,14 +89,16 @@ BUSINESS_PLANS = [
|
|||
'privateRepos': 125,
|
||||
'stripeId': 'bus-large',
|
||||
'audience': 'For large businesses',
|
||||
'bus_features': True,
|
||||
'deprecated': False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_plan(id):
|
||||
def get_plan(plan_id):
|
||||
""" Returns the plan with the given ID or None if none. """
|
||||
for plan in itertools.chain(USER_PLANS, BUSINESS_PLANS):
|
||||
if plan['stripeId'] == id:
|
||||
for plan in PLANS:
|
||||
if plan['stripeId'] == plan_id:
|
||||
return plan
|
||||
|
||||
return None
|
||||
|
|
408
endpoints/api.py
|
@ -12,7 +12,7 @@ from collections import defaultdict
|
|||
|
||||
from data import model
|
||||
from data.queue import dockerfile_build_queue
|
||||
from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan
|
||||
from data.plans import PLANS, get_plan
|
||||
from app import app
|
||||
from util.email import send_confirmation_email, send_recovery_email
|
||||
from util.names import parse_repository_name, format_robot_username
|
||||
|
@ -25,20 +25,53 @@ from auth.permissions import (ReadRepositoryPermission,
|
|||
AdministerOrganizationPermission,
|
||||
OrganizationMemberPermission,
|
||||
ViewTeamPermission)
|
||||
from endpoints import registry
|
||||
from endpoints.web import common_login
|
||||
from endpoints.common import common_login
|
||||
from util.cache import cache_control
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
store = app.config['STORAGE']
|
||||
user_files = app.config['USERFILES']
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
route_data = None
|
||||
|
||||
def get_route_data():
|
||||
global route_data
|
||||
if route_data:
|
||||
return route_data
|
||||
|
||||
routes = []
|
||||
for rule in app.url_map.iter_rules():
|
||||
if rule.rule.startswith('/api/'):
|
||||
endpoint_method = globals()[rule.endpoint]
|
||||
is_internal = '__internal_call' in dir(endpoint_method)
|
||||
is_org_api = '__user_call' in dir(endpoint_method)
|
||||
methods = list(rule.methods.difference(['HEAD', 'OPTIONS']))
|
||||
|
||||
route = {
|
||||
'name': rule.endpoint,
|
||||
'methods': methods,
|
||||
'path': rule.rule,
|
||||
'parameters': list(rule.arguments)
|
||||
}
|
||||
|
||||
if is_org_api:
|
||||
route['user_method'] = endpoint_method.__user_call
|
||||
|
||||
routes.append(route)
|
||||
|
||||
route_data = {
|
||||
'endpoints': routes
|
||||
}
|
||||
return route_data
|
||||
|
||||
|
||||
def log_action(kind, user_or_orgname, metadata={}, repo=None):
|
||||
performer = current_user.db_user()
|
||||
model.log_action(kind, user_or_orgname, performer=performer, ip=request.remote_addr,
|
||||
metadata=metadata, repository=repo)
|
||||
model.log_action(kind, user_or_orgname, performer=performer,
|
||||
ip=request.remote_addr, metadata=metadata, repository=repo)
|
||||
|
||||
|
||||
def api_login_required(f):
|
||||
@wraps(f)
|
||||
|
@ -58,26 +91,51 @@ def api_login_required(f):
|
|||
return decorated_view
|
||||
|
||||
|
||||
def internal_api_call(f):
|
||||
@wraps(f)
|
||||
def decorated_view(*args, **kwargs):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
decorated_view.__internal_call = True
|
||||
return decorated_view
|
||||
|
||||
|
||||
def org_api_call(user_call_name):
|
||||
def internal_decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_view(*args, **kwargs):
|
||||
return f(*args, **kwargs)
|
||||
|
||||
decorated_view.__user_call = user_call_name
|
||||
return decorated_view
|
||||
|
||||
return internal_decorator
|
||||
|
||||
@app.errorhandler(model.DataModelException)
|
||||
def handle_dme(ex):
|
||||
return make_response(ex.message, 400)
|
||||
|
||||
|
||||
@app.errorhandler(KeyError)
|
||||
def handle_dme(ex):
|
||||
def handle_dme_key_error(ex):
|
||||
return make_response(ex.message, 400)
|
||||
|
||||
|
||||
@app.route('/api/discovery')
|
||||
def discovery():
|
||||
return jsonify(get_route_data())
|
||||
|
||||
|
||||
@app.route('/api/')
|
||||
@internal_api_call
|
||||
def welcome():
|
||||
return make_response('welcome', 200)
|
||||
|
||||
|
||||
@app.route('/api/plans/')
|
||||
def plans_list():
|
||||
def list_plans():
|
||||
return jsonify({
|
||||
'user': USER_PLANS,
|
||||
'business': BUSINESS_PLANS,
|
||||
'plans': PLANS,
|
||||
})
|
||||
|
||||
|
||||
|
@ -93,6 +151,14 @@ def user_view(user):
|
|||
|
||||
organizations = model.get_user_organizations(user.username)
|
||||
|
||||
def login_view(login):
|
||||
return {
|
||||
'service': login.service.name,
|
||||
'service_identifier': login.service_ident,
|
||||
}
|
||||
|
||||
logins = model.list_federated_logins(user)
|
||||
|
||||
return {
|
||||
'verified': user.verified,
|
||||
'anonymous': False,
|
||||
|
@ -101,12 +167,14 @@ def user_view(user):
|
|||
'gravatar': compute_hash(user.email),
|
||||
'askForPassword': user.password_hash is None,
|
||||
'organizations': [org_view(o) for o in organizations],
|
||||
'logins': [login_view(login) for login in logins],
|
||||
'can_create_repo': True,
|
||||
'invoice_email': user.invoice_email
|
||||
}
|
||||
|
||||
|
||||
@app.route('/api/user/', methods=['GET'])
|
||||
@internal_api_call
|
||||
def get_logged_in_user():
|
||||
if current_user.is_anonymous():
|
||||
return jsonify({'anonymous': True})
|
||||
|
@ -118,8 +186,30 @@ def get_logged_in_user():
|
|||
return jsonify(user_view(user))
|
||||
|
||||
|
||||
@app.route('/api/user/private', methods=['GET'])
|
||||
@api_login_required
|
||||
@internal_api_call
|
||||
def get_user_private_count():
|
||||
user = current_user.db_user()
|
||||
private_repos = model.get_private_repo_count(user.username)
|
||||
repos_allowed = 0
|
||||
|
||||
if user.stripe_id:
|
||||
cus = stripe.Customer.retrieve(user.stripe_id)
|
||||
if cus.subscription:
|
||||
plan = get_plan(cus.subscription.plan.id)
|
||||
if plan:
|
||||
repos_allowed = plan['privateRepos']
|
||||
|
||||
return jsonify({
|
||||
'privateCount': private_repos,
|
||||
'reposAllowed': repos_allowed
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/user/convert', methods=['POST'])
|
||||
@api_login_required
|
||||
@internal_api_call
|
||||
def convert_user_to_organization():
|
||||
user = current_user.db_user()
|
||||
convert_data = request.get_json()
|
||||
|
@ -144,7 +234,7 @@ def convert_user_to_organization():
|
|||
|
||||
# Subscribe the organization to the new plan.
|
||||
plan = convert_data['plan']
|
||||
subscribe(user, plan, None, BUSINESS_PLANS)
|
||||
subscribe(user, plan, None, True) # Require business plans
|
||||
|
||||
# Convert the user to an organization.
|
||||
model.convert_user_to_organization(user, model.get_user(admin_username))
|
||||
|
@ -152,11 +242,11 @@ def convert_user_to_organization():
|
|||
|
||||
# And finally login with the admin credentials.
|
||||
return conduct_signin(admin_username, admin_password)
|
||||
|
||||
|
||||
|
||||
@app.route('/api/user/', methods=['PUT'])
|
||||
@api_login_required
|
||||
@internal_api_call
|
||||
def change_user_details():
|
||||
user = current_user.db_user()
|
||||
|
||||
|
@ -183,7 +273,8 @@ def change_user_details():
|
|||
|
||||
|
||||
@app.route('/api/user/', methods=['POST'])
|
||||
def create_user_api():
|
||||
@internal_api_call
|
||||
def create_new_user():
|
||||
user_data = request.get_json()
|
||||
|
||||
existing_user = model.get_user(user_data['username'])
|
||||
|
@ -209,7 +300,8 @@ def create_user_api():
|
|||
|
||||
|
||||
@app.route('/api/signin', methods=['POST'])
|
||||
def signin_api():
|
||||
@internal_api_call
|
||||
def signin_user():
|
||||
signin_data = request.get_json()
|
||||
|
||||
username = signin_data['username']
|
||||
|
@ -243,6 +335,7 @@ def conduct_signin(username, password):
|
|||
|
||||
@app.route("/api/signout", methods=['POST'])
|
||||
@api_login_required
|
||||
@internal_api_call
|
||||
def logout():
|
||||
logout_user()
|
||||
identity_changed.send(app, identity=AnonymousIdentity())
|
||||
|
@ -250,7 +343,8 @@ def logout():
|
|||
|
||||
|
||||
@app.route("/api/recovery", methods=['POST'])
|
||||
def send_recovery():
|
||||
@internal_api_call
|
||||
def request_recovery_email():
|
||||
email = request.get_json()['email']
|
||||
code = model.create_reset_password_email_code(email)
|
||||
send_recovery_email(email, code.code)
|
||||
|
@ -272,9 +366,10 @@ def get_matching_users(prefix):
|
|||
def get_matching_entities(prefix):
|
||||
teams = []
|
||||
|
||||
namespace_name = request.args.get('namespace', None)
|
||||
namespace_name = request.args.get('namespace', '')
|
||||
robot_namespace = None
|
||||
organization = None
|
||||
|
||||
try:
|
||||
organization = model.get_organization(namespace_name)
|
||||
|
||||
|
@ -308,7 +403,7 @@ def get_matching_entities(prefix):
|
|||
'is_robot': user.is_robot,
|
||||
}
|
||||
|
||||
if user.is_org_member is not None:
|
||||
if organization is not None:
|
||||
user_json['is_org_member'] = user.is_robot or user.is_org_member
|
||||
|
||||
return user_json
|
||||
|
@ -334,7 +429,8 @@ def team_view(orgname, team):
|
|||
|
||||
@app.route('/api/organization/', methods=['POST'])
|
||||
@api_login_required
|
||||
def create_organization_api():
|
||||
@internal_api_call
|
||||
def create_organization():
|
||||
org_data = request.get_json()
|
||||
existing = None
|
||||
|
||||
|
@ -398,6 +494,7 @@ def get_organization(orgname):
|
|||
|
||||
@app.route('/api/organization/<orgname>', methods=['PUT'])
|
||||
@api_login_required
|
||||
@org_api_call('change_user_details')
|
||||
def change_organization_details(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
|
@ -406,7 +503,7 @@ def change_organization_details(orgname):
|
|||
except model.InvalidOrganizationException:
|
||||
abort(404)
|
||||
|
||||
org_data = request.get_json();
|
||||
org_data = request.get_json()
|
||||
if 'invoice_email' in org_data:
|
||||
logger.debug('Changing invoice_email for organization: %s', org.username)
|
||||
model.change_invoice_email(org, org_data['invoice_email'])
|
||||
|
@ -456,7 +553,7 @@ def get_organization_member(orgname, membername):
|
|||
abort(404)
|
||||
|
||||
member_dict = None
|
||||
member_teams = model.get_organization_members_with_teams(org, membername = membername)
|
||||
member_teams = model.get_organization_members_with_teams(org, membername=membername)
|
||||
for member in member_teams:
|
||||
if not member_dict:
|
||||
member_dict = {'username': member.user.username,
|
||||
|
@ -475,6 +572,7 @@ def get_organization_member(orgname, membername):
|
|||
|
||||
@app.route('/api/organization/<orgname>/private', methods=['GET'])
|
||||
@api_login_required
|
||||
@internal_api_call
|
||||
def get_organization_private_allowed(orgname):
|
||||
permission = CreateRepositoryPermission(orgname)
|
||||
if permission.can():
|
||||
|
@ -484,7 +582,11 @@ def get_organization_private_allowed(orgname):
|
|||
if organization.stripe_id:
|
||||
cus = stripe.Customer.retrieve(organization.stripe_id)
|
||||
if cus.subscription:
|
||||
repos_allowed = get_plan(cus.subscription.plan.id)
|
||||
repos_allowed = 0
|
||||
plan = get_plan(cus.subscription.plan.id)
|
||||
if plan:
|
||||
repos_allowed = plan['privateRepos']
|
||||
|
||||
return jsonify({
|
||||
'privateAllowed': (private_repos < repos_allowed)
|
||||
})
|
||||
|
@ -526,17 +628,20 @@ def update_organization_team(orgname, teamname):
|
|||
log_action('org_create_team', orgname, {'team': teamname})
|
||||
|
||||
if is_existing:
|
||||
if 'description' in details and team.description != details['description']:
|
||||
if ('description' in details and
|
||||
team.description != details['description']):
|
||||
team.description = details['description']
|
||||
team.save()
|
||||
log_action('org_set_team_description', orgname, {'team': teamname, 'description': team.description})
|
||||
log_action('org_set_team_description', orgname,
|
||||
{'team': teamname, 'description': team.description})
|
||||
|
||||
if 'role' in details:
|
||||
role = model.get_team_org_role(team).name
|
||||
if role != details['role']:
|
||||
team = model.set_team_org_permission(team, details['role'],
|
||||
current_user.db_user().username)
|
||||
log_action('org_set_team_role', orgname, {'team': teamname, 'role': details['role']})
|
||||
log_action('org_set_team_role', orgname,
|
||||
{'team': teamname, 'role': details['role']})
|
||||
|
||||
resp = jsonify(team_view(orgname, team))
|
||||
if not is_existing:
|
||||
|
@ -604,7 +709,8 @@ def update_organization_team_member(orgname, teamname, membername):
|
|||
|
||||
# Add the user to the team.
|
||||
model.add_user_to_team(user, team)
|
||||
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname})
|
||||
log_action('org_add_team_member', orgname,
|
||||
{'member': membername, 'team': teamname})
|
||||
return jsonify(member_view(user))
|
||||
|
||||
abort(403)
|
||||
|
@ -619,7 +725,8 @@ def delete_organization_team_member(orgname, teamname, membername):
|
|||
# Remote the user from the team.
|
||||
invoking_user = current_user.db_user().username
|
||||
model.remove_user_from_team(orgname, teamname, membername, invoking_user)
|
||||
log_action('org_remove_team_member', orgname, {'member': membername, 'team': teamname})
|
||||
log_action('org_remove_team_member', orgname,
|
||||
{'member': membername, 'team': teamname})
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
abort(403)
|
||||
|
@ -627,7 +734,7 @@ def delete_organization_team_member(orgname, teamname, membername):
|
|||
|
||||
@app.route('/api/repository', methods=['POST'])
|
||||
@api_login_required
|
||||
def create_repo_api():
|
||||
def create_repo():
|
||||
owner = current_user.db_user()
|
||||
req = request.get_json()
|
||||
namespace_name = req['namespace'] if 'namespace' in req else owner.username
|
||||
|
@ -648,7 +755,9 @@ def create_repo_api():
|
|||
repo.description = req['description']
|
||||
repo.save()
|
||||
|
||||
log_action('create_repo', namespace_name, {'repo': repository_name, 'namespace': namespace_name}, repo=repo)
|
||||
log_action('create_repo', namespace_name,
|
||||
{'repo': repository_name, 'namespace': namespace_name},
|
||||
repo=repo)
|
||||
return jsonify({
|
||||
'namespace': namespace_name,
|
||||
'name': repository_name
|
||||
|
@ -658,7 +767,7 @@ def create_repo_api():
|
|||
|
||||
|
||||
@app.route('/api/find/repository', methods=['GET'])
|
||||
def match_repos_api():
|
||||
def find_repos():
|
||||
prefix = request.args.get('query', '')
|
||||
|
||||
def repo_view(repo):
|
||||
|
@ -681,7 +790,7 @@ def match_repos_api():
|
|||
|
||||
|
||||
@app.route('/api/repository/', methods=['GET'])
|
||||
def list_repos_api():
|
||||
def list_repos():
|
||||
def repo_view(repo_obj):
|
||||
return {
|
||||
'namespace': repo_obj.namespace,
|
||||
|
@ -690,11 +799,13 @@ def list_repos_api():
|
|||
'is_public': repo_obj.visibility.name == 'public',
|
||||
}
|
||||
|
||||
page = request.args.get('page', None)
|
||||
limit = request.args.get('limit', None)
|
||||
namespace_filter = request.args.get('namespace', None)
|
||||
include_public = request.args.get('public', 'true')
|
||||
include_private = request.args.get('private', 'true')
|
||||
sort = request.args.get('sort', 'false')
|
||||
include_count = request.args.get('count', 'false')
|
||||
|
||||
try:
|
||||
limit = int(limit) if limit else None
|
||||
|
@ -703,28 +814,45 @@ def list_repos_api():
|
|||
|
||||
include_public = include_public == 'true'
|
||||
include_private = include_private == 'true'
|
||||
include_count = include_count == 'true'
|
||||
sort = sort == 'true'
|
||||
if page:
|
||||
try:
|
||||
page = int(page)
|
||||
except:
|
||||
page = None
|
||||
|
||||
username = None
|
||||
if current_user.is_authenticated() and include_private:
|
||||
username = current_user.db_user().username
|
||||
|
||||
repo_query = model.get_visible_repositories(username, limit=limit,
|
||||
repo_count = None
|
||||
if include_count:
|
||||
repo_count = model.get_visible_repository_count(username,
|
||||
include_public=include_public,
|
||||
sort=sort,
|
||||
namespace=namespace_filter)
|
||||
|
||||
repo_query = model.get_visible_repositories(username, limit=limit, page=page,
|
||||
include_public=include_public,
|
||||
sort=sort,
|
||||
namespace=namespace_filter)
|
||||
|
||||
repos = [repo_view(repo) for repo in repo_query]
|
||||
response = {
|
||||
'repositories': repos
|
||||
}
|
||||
|
||||
if include_count:
|
||||
response['count'] = repo_count
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>', methods=['PUT'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def update_repo_api(namespace, repository):
|
||||
def update_repo(namespace, repository):
|
||||
permission = ModifyRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
repo = model.get_repository(namespace, repository)
|
||||
|
@ -733,7 +861,8 @@ def update_repo_api(namespace, repository):
|
|||
repo.description = values['description']
|
||||
repo.save()
|
||||
|
||||
log_action('set_repo_description', namespace, {'repo': repository, 'description': values['description']},
|
||||
log_action('set_repo_description', namespace,
|
||||
{'repo': repository, 'description': values['description']},
|
||||
repo=repo)
|
||||
return jsonify({
|
||||
'success': True
|
||||
|
@ -746,14 +875,15 @@ def update_repo_api(namespace, repository):
|
|||
methods=['POST'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def change_repo_visibility_api(namespace, repository):
|
||||
def change_repo_visibility(namespace, repository):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
repo = model.get_repository(namespace, repository)
|
||||
if repo:
|
||||
values = request.get_json()
|
||||
model.set_repository_visibility(repo, values['visibility'])
|
||||
log_action('change_repo_visibility', namespace, {'repo': repository, 'visibility': values['visibility']},
|
||||
log_action('change_repo_visibility', namespace,
|
||||
{'repo': repository, 'visibility': values['visibility']},
|
||||
repo=repo)
|
||||
return jsonify({
|
||||
'success': True
|
||||
|
@ -769,8 +899,8 @@ def delete_repository(namespace, repository):
|
|||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
model.purge_repository(namespace, repository)
|
||||
registry.delete_repository_storage(namespace, repository)
|
||||
log_action('delete_repo', namespace, {'repo': repository, 'namespace': namespace})
|
||||
log_action('delete_repo', namespace,
|
||||
{'repo': repository, 'namespace': namespace})
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
abort(403)
|
||||
|
@ -781,14 +911,16 @@ def image_view(image):
|
|||
'id': image.docker_image_id,
|
||||
'created': image.created,
|
||||
'comment': image.comment,
|
||||
'command': json.loads(image.command) if image.command else None,
|
||||
'ancestors': image.ancestors,
|
||||
'dbid': image.id,
|
||||
'size': image.image_size,
|
||||
}
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>', methods=['GET'])
|
||||
@parse_repository_name
|
||||
def get_repo_api(namespace, repository):
|
||||
def get_repo(namespace, repository):
|
||||
logger.debug('Get repo: %s/%s' % (namespace, repository))
|
||||
|
||||
def tag_view(tag):
|
||||
|
@ -849,15 +981,15 @@ def get_repo_builds(namespace, repository):
|
|||
return node_status
|
||||
|
||||
# If there was no status url, do the best we can
|
||||
# The format of this block should mirror that of the buildserver.
|
||||
return {
|
||||
'id': build_obj.id,
|
||||
'total_commands': None,
|
||||
'total_images': None,
|
||||
'current_command': None,
|
||||
'current_image': None,
|
||||
'image_completion_percent': None,
|
||||
'push_completion': 0.0,
|
||||
'status': build_obj.phase,
|
||||
'message': None,
|
||||
'image_completion': {},
|
||||
}
|
||||
|
||||
builds = model.list_repository_builds(namespace, repository)
|
||||
|
@ -887,7 +1019,8 @@ def request_repo_build(namespace, repository):
|
|||
dockerfile_build_queue.put(json.dumps({'build_id': build_request.id}))
|
||||
|
||||
log_action('build_dockerfile', namespace,
|
||||
{'repo': repository, 'namespace': namespace, 'fileid': dockerfile_id}, repo=repo)
|
||||
{'repo': repository, 'namespace': namespace,
|
||||
'fileid': dockerfile_id}, repo=repo)
|
||||
|
||||
resp = jsonify({
|
||||
'started': True
|
||||
|
@ -918,7 +1051,8 @@ def create_webhook(namespace, repository):
|
|||
resp.headers['Location'] = url_for('get_webhook', repository=repo_string,
|
||||
public_id=webhook.public_id)
|
||||
log_action('add_repo_webhook', namespace,
|
||||
{'repo': repository, 'webhook_id': webhook.public_id}, repo=repo)
|
||||
{'repo': repository, 'webhook_id': webhook.public_id},
|
||||
repo=repo)
|
||||
return resp
|
||||
|
||||
abort(403) # Permissions denied
|
||||
|
@ -969,6 +1103,7 @@ def delete_webhook(namespace, repository, public_id):
|
|||
|
||||
@app.route('/api/filedrop/', methods=['POST'])
|
||||
@api_login_required
|
||||
@internal_api_call
|
||||
def get_filedrop_url():
|
||||
mime_type = request.get_json()['mimeType']
|
||||
(url, file_id) = user_files.prepare_for_drop(mime_type)
|
||||
|
@ -1051,6 +1186,24 @@ def get_image_changes(namespace, repository, image_id):
|
|||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/tag/<tag>',
|
||||
methods=['DELETE'])
|
||||
@parse_repository_name
|
||||
def delete_full_tag(namespace, repository, tag):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
model.delete_tag_and_images(namespace, repository, tag)
|
||||
|
||||
username = current_user.db_user().username
|
||||
log_action('delete_tag', namespace,
|
||||
{'username': username, 'repo': repository, 'tag': tag},
|
||||
repo=model.get_repository(namespace, repository))
|
||||
|
||||
return make_response('Deleted', 204)
|
||||
|
||||
abort(403) # Permission denied
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/tag/<tag>/images',
|
||||
methods=['GET'])
|
||||
@parse_repository_name
|
||||
|
@ -1118,7 +1271,8 @@ def list_repo_user_permissions(namespace, repository):
|
|||
current_func = role_view_func
|
||||
|
||||
def wrapped_role_org_view(repo_perm):
|
||||
return wrap_role_view_org(current_func(repo_perm), repo_perm.user, org_members)
|
||||
return wrap_role_view_org(current_func(repo_perm), repo_perm.user,
|
||||
org_members)
|
||||
|
||||
role_view_func = wrapped_role_org_view
|
||||
|
||||
|
@ -1203,7 +1357,8 @@ def change_user_permissions(namespace, repository, username):
|
|||
return error_resp
|
||||
|
||||
log_action('change_repo_permission', namespace,
|
||||
{'username': username, 'repo': repository, 'role': new_permission['role']},
|
||||
{'username': username, 'repo': repository,
|
||||
'role': new_permission['role']},
|
||||
repo=model.get_repository(namespace, repository))
|
||||
|
||||
resp = jsonify(perm_view)
|
||||
|
@ -1230,7 +1385,8 @@ def change_team_permissions(namespace, repository, teamname):
|
|||
new_permission['role'])
|
||||
|
||||
log_action('change_repo_permission', namespace,
|
||||
{'team': teamname, 'repo': repository, 'role': new_permission['role']},
|
||||
{'team': teamname, 'repo': repository,
|
||||
'role': new_permission['role']},
|
||||
repo=model.get_repository(namespace, repository))
|
||||
|
||||
resp = jsonify(role_view(perm))
|
||||
|
@ -1257,7 +1413,8 @@ def delete_user_permissions(namespace, repository, username):
|
|||
error_resp.status_code = 400
|
||||
return error_resp
|
||||
|
||||
log_action('delete_repo_permission', namespace, {'username': username, 'repo': repository},
|
||||
log_action('delete_repo_permission', namespace,
|
||||
{'username': username, 'repo': repository},
|
||||
repo=model.get_repository(namespace, repository))
|
||||
|
||||
return make_response('Deleted', 204)
|
||||
|
@ -1274,7 +1431,8 @@ def delete_team_permissions(namespace, repository, teamname):
|
|||
if permission.can():
|
||||
model.delete_team_permission(teamname, namespace, repository)
|
||||
|
||||
log_action('delete_repo_permission', namespace, {'team': teamname, 'repo': repository},
|
||||
log_action('delete_repo_permission', namespace,
|
||||
{'team': teamname, 'repo': repository},
|
||||
repo=model.get_repository(namespace, repository))
|
||||
|
||||
return make_response('Deleted', 204)
|
||||
|
@ -1328,7 +1486,8 @@ def create_token(namespace, repository):
|
|||
token = model.create_delegate_token(namespace, repository,
|
||||
token_params['friendlyName'])
|
||||
|
||||
log_action('add_repo_accesstoken', namespace, {'repo': repository, 'token': token_params['friendlyName']},
|
||||
log_action('add_repo_accesstoken', namespace,
|
||||
{'repo': repository, 'token': token_params['friendlyName']},
|
||||
repo = model.get_repository(namespace, repository))
|
||||
|
||||
resp = jsonify(token_view(token))
|
||||
|
@ -1353,7 +1512,8 @@ def change_token(namespace, repository, code):
|
|||
new_permission['role'])
|
||||
|
||||
log_action('change_repo_permission', namespace,
|
||||
{'repo': repository, 'token': token.friendly_name, 'code': code, 'role': new_permission['role']},
|
||||
{'repo': repository, 'token': token.friendly_name, 'code': code,
|
||||
'role': new_permission['role']},
|
||||
repo = model.get_repository(namespace, repository))
|
||||
|
||||
resp = jsonify(token_view(token))
|
||||
|
@ -1372,7 +1532,8 @@ def delete_token(namespace, repository, code):
|
|||
token = model.delete_delegate_token(namespace, repository, code)
|
||||
|
||||
log_action('delete_repo_accesstoken', namespace,
|
||||
{'repo': repository, 'token': token.friendly_name, 'code': code},
|
||||
{'repo': repository, 'token': token.friendly_name,
|
||||
'code': code},
|
||||
repo = model.get_repository(namespace, repository))
|
||||
|
||||
return make_response('Deleted', 204)
|
||||
|
@ -1391,14 +1552,17 @@ def subscription_view(stripe_subscription, used_repos):
|
|||
|
||||
@app.route('/api/user/card', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_user_card_api():
|
||||
@internal_api_call
|
||||
def get_user_card():
|
||||
user = current_user.db_user()
|
||||
return get_card(user)
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/card', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_org_card_api(orgname):
|
||||
@internal_api_call
|
||||
@org_api_call('get_user_card')
|
||||
def get_org_card(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
organization = model.get_organization(orgname)
|
||||
|
@ -1409,7 +1573,8 @@ def get_org_card_api(orgname):
|
|||
|
||||
@app.route('/api/user/card', methods=['POST'])
|
||||
@api_login_required
|
||||
def set_user_card_api():
|
||||
@internal_api_call
|
||||
def set_user_card():
|
||||
user = current_user.db_user()
|
||||
token = request.get_json()['token']
|
||||
response = set_card(user, token)
|
||||
|
@ -1419,7 +1584,8 @@ def set_user_card_api():
|
|||
|
||||
@app.route('/api/organization/<orgname>/card', methods=['POST'])
|
||||
@api_login_required
|
||||
def set_org_card_api(orgname):
|
||||
@org_api_call('set_user_card')
|
||||
def set_org_card(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
organization = model.get_organization(orgname)
|
||||
|
@ -1461,21 +1627,22 @@ def get_card(user):
|
|||
|
||||
if default_card:
|
||||
card_info = {
|
||||
'owner': card.name,
|
||||
'type': card.type,
|
||||
'last4': card.last4
|
||||
'owner': default_card.name,
|
||||
'type': default_card.type,
|
||||
'last4': default_card.last4
|
||||
}
|
||||
|
||||
return jsonify({'card': card_info})
|
||||
|
||||
@app.route('/api/user/plan', methods=['PUT'])
|
||||
@api_login_required
|
||||
def subscribe_api():
|
||||
@internal_api_call
|
||||
def update_user_subscription():
|
||||
request_data = request.get_json()
|
||||
plan = request_data['plan']
|
||||
token = request_data['token'] if 'token' in request_data else None
|
||||
user = current_user.db_user()
|
||||
return subscribe(user, plan, token, USER_PLANS)
|
||||
return subscribe(user, plan, token, False) # Business features not required
|
||||
|
||||
|
||||
def carderror_response(e):
|
||||
|
@ -1486,15 +1653,22 @@ def carderror_response(e):
|
|||
return resp
|
||||
|
||||
|
||||
def subscribe(user, plan, token, accepted_plans):
|
||||
def subscribe(user, plan, token, require_business_plan):
|
||||
plan_found = None
|
||||
for plan_obj in accepted_plans:
|
||||
for plan_obj in PLANS:
|
||||
if plan_obj['stripeId'] == plan:
|
||||
plan_found = plan_obj
|
||||
|
||||
if not plan_found:
|
||||
if not plan_found or plan_found['deprecated']:
|
||||
logger.warning('Plan not found or deprecated: %s', plan)
|
||||
abort(404)
|
||||
|
||||
if (require_business_plan and not plan_found['bus_features'] and not
|
||||
plan_found['price'] == 0):
|
||||
logger.warning('Business attempting to subscribe to personal plan: %s',
|
||||
user.username)
|
||||
abort(400)
|
||||
|
||||
private_repos = model.get_private_repo_count(user.username)
|
||||
|
||||
# This is the default response
|
||||
|
@ -1553,9 +1727,32 @@ def subscribe(user, plan, token, accepted_plans):
|
|||
return resp
|
||||
|
||||
|
||||
@app.route('/api/user/invoices', methods=['GET'])
|
||||
@api_login_required
|
||||
def list_user_invoices():
|
||||
user = current_user.db_user()
|
||||
if not user.stripe_id:
|
||||
abort(404)
|
||||
|
||||
return get_invoices(user.stripe_id)
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/invoices', methods=['GET'])
|
||||
@api_login_required
|
||||
def org_invoices_api(orgname):
|
||||
@org_api_call('list_user_invoices')
|
||||
def list_org_invoices(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
organization = model.get_organization(orgname)
|
||||
if not organization.stripe_id:
|
||||
abort(404)
|
||||
|
||||
return get_invoices(organization.stripe_id)
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
def get_invoices(customer_id):
|
||||
def invoice_view(i):
|
||||
return {
|
||||
'id': i.id,
|
||||
|
@ -1571,37 +1768,32 @@ def org_invoices_api(orgname):
|
|||
'plan': i.lines.data[0].plan.id if i.lines.data[0].plan else None
|
||||
}
|
||||
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
organization = model.get_organization(orgname)
|
||||
if not organization.stripe_id:
|
||||
abort(404)
|
||||
|
||||
invoices = stripe.Invoice.all(customer=organization.stripe_id, count=12)
|
||||
return jsonify({
|
||||
'invoices': [invoice_view(i) for i in invoices.data]
|
||||
})
|
||||
|
||||
abort(403)
|
||||
invoices = stripe.Invoice.all(customer=customer_id, count=12)
|
||||
return jsonify({
|
||||
'invoices': [invoice_view(i) for i in invoices.data]
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/organization/<orgname>/plan', methods=['PUT'])
|
||||
@api_login_required
|
||||
def subscribe_org_api(orgname):
|
||||
@internal_api_call
|
||||
@org_api_call('update_user_subscription')
|
||||
def update_org_subscription(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
request_data = request.get_json()
|
||||
plan = request_data['plan']
|
||||
token = request_data['token'] if 'token' in request_data else None
|
||||
organization = model.get_organization(orgname)
|
||||
return subscribe(organization, plan, token, BUSINESS_PLANS)
|
||||
return subscribe(organization, plan, token, True) # Business plan required
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/api/user/plan', methods=['GET'])
|
||||
@api_login_required
|
||||
def get_subscription():
|
||||
@internal_api_call
|
||||
def get_user_subscription():
|
||||
user = current_user.db_user()
|
||||
private_repos = model.get_private_repo_count(user.username)
|
||||
|
||||
|
@ -1619,6 +1811,8 @@ def get_subscription():
|
|||
|
||||
@app.route('/api/organization/<orgname>/plan', methods=['GET'])
|
||||
@api_login_required
|
||||
@internal_api_call
|
||||
@org_api_call('get_user_subscription')
|
||||
def get_org_subscription(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
|
@ -1631,7 +1825,7 @@ def get_org_subscription(orgname):
|
|||
return jsonify(subscription_view(cus.subscription, private_repos))
|
||||
|
||||
return jsonify({
|
||||
'plan': 'bus-free',
|
||||
'plan': 'free',
|
||||
'usedPrivateRepos': private_repos,
|
||||
})
|
||||
|
||||
|
@ -1657,6 +1851,7 @@ def get_user_robots():
|
|||
|
||||
@app.route('/api/organization/<orgname>/robots', methods=['GET'])
|
||||
@api_login_required
|
||||
@org_api_call('get_user_robots')
|
||||
def get_org_robots(orgname):
|
||||
permission = OrganizationMemberPermission(orgname)
|
||||
if permission.can():
|
||||
|
@ -1670,7 +1865,7 @@ def get_org_robots(orgname):
|
|||
|
||||
@app.route('/api/user/robots/<robot_shortname>', methods=['PUT'])
|
||||
@api_login_required
|
||||
def create_robot(robot_shortname):
|
||||
def create_user_robot(robot_shortname):
|
||||
parent = current_user.db_user()
|
||||
robot, password = model.create_robot(robot_shortname, parent)
|
||||
resp = jsonify(robot_view(robot.username, password))
|
||||
|
@ -1682,6 +1877,7 @@ def create_robot(robot_shortname):
|
|||
@app.route('/api/organization/<orgname>/robots/<robot_shortname>',
|
||||
methods=['PUT'])
|
||||
@api_login_required
|
||||
@org_api_call('create_user_robot')
|
||||
def create_org_robot(orgname, robot_shortname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
|
@ -1697,7 +1893,7 @@ def create_org_robot(orgname, robot_shortname):
|
|||
|
||||
@app.route('/api/user/robots/<robot_shortname>', methods=['DELETE'])
|
||||
@api_login_required
|
||||
def delete_robot(robot_shortname):
|
||||
def delete_user_robot(robot_shortname):
|
||||
parent = current_user.db_user()
|
||||
model.delete_robot(format_robot_username(parent.username, robot_shortname))
|
||||
log_action('delete_robot', parent.username, {'robot': robot_shortname})
|
||||
|
@ -1707,6 +1903,7 @@ def delete_robot(robot_shortname):
|
|||
@app.route('/api/organization/<orgname>/robots/<robot_shortname>',
|
||||
methods=['DELETE'])
|
||||
@api_login_required
|
||||
@org_api_call('delete_user_robot')
|
||||
def delete_org_robot(orgname, robot_shortname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
|
@ -1718,27 +1915,27 @@ def delete_org_robot(orgname, robot_shortname):
|
|||
|
||||
|
||||
def log_view(log):
|
||||
view = {
|
||||
'kind': log.kind.name,
|
||||
'metadata': json.loads(log.metadata_json),
|
||||
'ip': log.ip,
|
||||
'datetime': log.datetime,
|
||||
view = {
|
||||
'kind': log.kind.name,
|
||||
'metadata': json.loads(log.metadata_json),
|
||||
'ip': log.ip,
|
||||
'datetime': log.datetime,
|
||||
}
|
||||
|
||||
if log.performer:
|
||||
view['performer'] = {
|
||||
'username': log.performer.username,
|
||||
'is_robot': log.performer.robot,
|
||||
}
|
||||
|
||||
if log.performer:
|
||||
view['performer'] = {
|
||||
'username': log.performer.username,
|
||||
'is_robot': log.performer.robot,
|
||||
}
|
||||
|
||||
return view
|
||||
return view
|
||||
|
||||
|
||||
|
||||
@app.route('/api/repository/<path:repository>/logs', methods=['GET'])
|
||||
@api_login_required
|
||||
@parse_repository_name
|
||||
def repo_logs_api(namespace, repository):
|
||||
def list_repo_logs(namespace, repository):
|
||||
permission = AdministerRepositoryPermission(namespace, repository)
|
||||
if permission.can():
|
||||
repo = model.get_repository(namespace, repository)
|
||||
|
@ -1754,19 +1951,33 @@ def repo_logs_api(namespace, repository):
|
|||
|
||||
@app.route('/api/organization/<orgname>/logs', methods=['GET'])
|
||||
@api_login_required
|
||||
def org_logs_api(orgname):
|
||||
@org_api_call('list_user_logs')
|
||||
def list_org_logs(orgname):
|
||||
permission = AdministerOrganizationPermission(orgname)
|
||||
if permission.can():
|
||||
performer_name = request.args.get('performer', None)
|
||||
start_time = request.args.get('starttime', None)
|
||||
end_time = request.args.get('endtime', None)
|
||||
|
||||
return get_logs(orgname, start_time, end_time, performer_name=performer_name)
|
||||
return get_logs(orgname, start_time, end_time,
|
||||
performer_name=performer_name)
|
||||
|
||||
abort(403)
|
||||
|
||||
|
||||
def get_logs(namespace, start_time, end_time, performer_name=None, repository=None):
|
||||
@app.route('/api/user/logs', methods=['GET'])
|
||||
@api_login_required
|
||||
def list_user_logs():
|
||||
performer_name = request.args.get('performer', None)
|
||||
start_time = request.args.get('starttime', None)
|
||||
end_time = request.args.get('endtime', None)
|
||||
|
||||
return get_logs(current_user.db_user().username, start_time, end_time,
|
||||
performer_name=performer_name)
|
||||
|
||||
|
||||
def get_logs(namespace, start_time, end_time, performer_name=None,
|
||||
repository=None):
|
||||
performer = None
|
||||
if performer_name:
|
||||
performer = model.get_user(performer_name)
|
||||
|
@ -1790,7 +2001,8 @@ def get_logs(namespace, start_time, end_time, performer_name=None, repository=No
|
|||
if not end_time:
|
||||
end_time = datetime.today()
|
||||
|
||||
logs = model.list_logs(namespace, start_time, end_time, performer = performer, repository=repository)
|
||||
logs = model.list_logs(namespace, start_time, end_time, performer=performer,
|
||||
repository=repository)
|
||||
return jsonify({
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
|
|
48
endpoints/common.py
Normal file
|
@ -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
|
|
@ -63,14 +63,14 @@ def create_user():
|
|||
model.load_token_data(password)
|
||||
return make_response('Verified', 201)
|
||||
except model.InvalidTokenException:
|
||||
abort(401)
|
||||
return make_response('Invalid access token.', 400)
|
||||
|
||||
elif '+' in username:
|
||||
try:
|
||||
model.verify_robot(username, password)
|
||||
return make_response('Verified', 201)
|
||||
except model.InvalidRobotException:
|
||||
abort(401)
|
||||
return make_response('Invalid robot account or password.', 400)
|
||||
|
||||
existing_user = model.get_user(username)
|
||||
if existing_user:
|
||||
|
@ -78,7 +78,7 @@ def create_user():
|
|||
if verified:
|
||||
return make_response('Verified', 201)
|
||||
else:
|
||||
abort(401)
|
||||
return make_response('Invalid password.', 400)
|
||||
else:
|
||||
# New user case
|
||||
new_user = model.create_user(username, password, user_data['email'])
|
||||
|
@ -134,8 +134,8 @@ def create_repository(namespace, repository):
|
|||
repo = model.get_repository(namespace, repository)
|
||||
|
||||
if not repo and get_authenticated_user() is None:
|
||||
logger.debug('Attempt to create new repository with token auth.')
|
||||
abort(400)
|
||||
logger.debug('Attempt to create new repository without user auth.')
|
||||
abort(401)
|
||||
|
||||
elif repo:
|
||||
permission = ModifyRepositoryPermission(namespace, repository)
|
||||
|
@ -158,10 +158,6 @@ def create_repository(namespace, repository):
|
|||
for existing in model.get_repository_images(namespace, repository):
|
||||
if existing.docker_image_id in new_repo_images:
|
||||
added_images.pop(existing.docker_image_id)
|
||||
else:
|
||||
logger.debug('Deleting existing image with id: %s' %
|
||||
existing.docker_image_id)
|
||||
existing.delete_instance(recursive=True)
|
||||
|
||||
for image_description in added_images.values():
|
||||
model.create_image(image_description['id'], repo)
|
||||
|
|
|
@ -43,6 +43,7 @@ def require_completion(f):
|
|||
def wrapper(namespace, repository, *args, **kwargs):
|
||||
if store.exists(store.image_mark_path(namespace, repository,
|
||||
kwargs['image_id'])):
|
||||
logger.warning('Image is already being uploaded: %s', kwargs['image_id'])
|
||||
abort(400) # 'Image is being uploaded, retry later')
|
||||
return f(namespace, repository, *args, **kwargs)
|
||||
return wrapper
|
||||
|
@ -87,6 +88,7 @@ def get_image_layer(namespace, repository, image_id, headers):
|
|||
try:
|
||||
return Response(store.stream_read(path), headers=headers)
|
||||
except IOError:
|
||||
logger.warning('Image not found: %s', image_id)
|
||||
abort(404) # 'Image not found', 404)
|
||||
|
||||
abort(403)
|
||||
|
@ -124,6 +126,11 @@ def put_image_layer(namespace, repository, image_id):
|
|||
store.stream_write(layer_path, sr)
|
||||
csums.append('sha256:{0}'.format(h.hexdigest()))
|
||||
try:
|
||||
image_size = tmp.tell()
|
||||
|
||||
# Save the size of the image.
|
||||
model.set_image_size(image_id, namespace, repository, image_size)
|
||||
|
||||
tmp.seek(0)
|
||||
csums.append(checksums.compute_tarsum(tmp, json_data))
|
||||
tmp.close()
|
||||
|
@ -141,7 +148,7 @@ def put_image_layer(namespace, repository, image_id):
|
|||
return make_response('true', 200)
|
||||
# We check if the checksums provided matches one the one we computed
|
||||
if checksum not in csums:
|
||||
logger.debug('put_image_layer: Wrong checksum')
|
||||
logger.warning('put_image_layer: Wrong checksum')
|
||||
abort(400) # 'Checksum mismatch, ignoring the layer')
|
||||
# Checksum is ok, we remove the marker
|
||||
store.remove(mark_path)
|
||||
|
@ -168,8 +175,10 @@ def put_image_checksum(namespace, repository, image_id):
|
|||
|
||||
checksum = request.headers.get('X-Docker-Checksum')
|
||||
if not checksum:
|
||||
logger.warning('Missing Image\'s checksum: %s', image_id)
|
||||
abort(400) # 'Missing Image\'s checksum')
|
||||
if not session.get('checksum'):
|
||||
logger.warning('Checksum not found in Cookie for image: %s', image_id)
|
||||
abort(400) # 'Checksum not found in Cookie')
|
||||
if not store.exists(store.image_json_path(namespace, repository, image_id)):
|
||||
abort(404) # 'Image not found', 404)
|
||||
|
@ -287,8 +296,11 @@ def put_image_json(namespace, repository, image_id):
|
|||
except json.JSONDecodeError:
|
||||
pass
|
||||
if not data or not isinstance(data, dict):
|
||||
logger.warning('Invalid JSON for image: %s json: %s', image_id,
|
||||
request.data)
|
||||
abort(400) # 'Invalid JSON')
|
||||
if 'id' not in data:
|
||||
logger.warning('Missing key `id\' in JSON for image: %s', image_id)
|
||||
abort(400) # 'Missing key `id\' in JSON')
|
||||
# Read the checksum
|
||||
checksum = request.headers.get('X-Docker-Checksum')
|
||||
|
@ -301,11 +313,14 @@ def put_image_json(namespace, repository, image_id):
|
|||
# We cleanup any old checksum in case it's a retry after a fail
|
||||
store.remove(store.image_checksum_path(namespace, repository, image_id))
|
||||
if image_id != data['id']:
|
||||
logger.warning('JSON data contains invalid id for image: %s', image_id)
|
||||
abort(400) # 'JSON data contains invalid id')
|
||||
parent_id = data.get('parent')
|
||||
if parent_id and not store.exists(store.image_json_path(namespace,
|
||||
repository,
|
||||
data['parent'])):
|
||||
logger.warning('Image depends on a non existing parent image: %s',
|
||||
image_id)
|
||||
abort(400) # 'Image depends on a non existing parent')
|
||||
json_path = store.image_json_path(namespace, repository, image_id)
|
||||
mark_path = store.image_mark_path(namespace, repository, image_id)
|
||||
|
@ -319,8 +334,10 @@ def put_image_json(namespace, repository, image_id):
|
|||
else:
|
||||
parent_obj = None
|
||||
|
||||
command_list = data.get('container_config', {}).get('Cmd', None)
|
||||
command = json.dumps(command_list) if command_list else None
|
||||
model.set_image_metadata(image_id, namespace, repository,
|
||||
data.get('created'), data.get('comment'),
|
||||
data.get('created'), data.get('comment'), command,
|
||||
parent_obj)
|
||||
store.put_content(mark_path, 'true')
|
||||
store.put_content(json_path, request.data)
|
||||
|
@ -328,14 +345,6 @@ def put_image_json(namespace, repository, image_id):
|
|||
return make_response('true', 200)
|
||||
|
||||
|
||||
def delete_repository_storage(namespace, repository):
|
||||
""" Caller should have already verified proper permissions. """
|
||||
repository_path = store.repository_namespace_path(namespace, repository)
|
||||
|
||||
logger.debug('Recursively deleting path: %s' % repository_path)
|
||||
store.remove(repository_path)
|
||||
|
||||
|
||||
def process_image_changes(namespace, repository, image_id):
|
||||
logger.debug('Generating diffs for image: %s' % image_id)
|
||||
|
||||
|
|
|
@ -1,19 +1,33 @@
|
|||
import math
|
||||
|
||||
from random import SystemRandom
|
||||
from flask import jsonify, send_file
|
||||
from flask import jsonify
|
||||
from app import app
|
||||
|
||||
|
||||
def generate_image_completion(rand_func):
|
||||
images = {}
|
||||
for image_id in range(rand_func.randint(1, 11)):
|
||||
total = int(math.pow(abs(rand_func.gauss(0, 1000)), 2))
|
||||
current = rand_func.randint(0, total)
|
||||
image_id = 'image_id_%s' % image_id
|
||||
images[image_id] = {
|
||||
'total': total,
|
||||
'current': current,
|
||||
}
|
||||
return images
|
||||
|
||||
|
||||
@app.route('/test/build/status', methods=['GET'])
|
||||
def generate_random_build_status():
|
||||
response = {
|
||||
'id': 1,
|
||||
'total_commands': None,
|
||||
'total_images': None,
|
||||
'current_command': None,
|
||||
'current_image': None,
|
||||
'image_completion_percent': None,
|
||||
'push_completion': 0.0,
|
||||
'status': None,
|
||||
'message': None,
|
||||
'image_completion': {},
|
||||
}
|
||||
|
||||
random = SystemRandom()
|
||||
|
@ -35,9 +49,8 @@ def generate_random_build_status():
|
|||
'pushing': {
|
||||
'total_commands': 7,
|
||||
'current_command': 7,
|
||||
'total_images': 11,
|
||||
'current_image': random.randint(1, 11),
|
||||
'image_completion_percent': random.randint(0, 100),
|
||||
'push_completion': random.random(),
|
||||
'image_completion': generate_image_completion(random),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
151
endpoints/web.py
|
@ -4,53 +4,33 @@ import stripe
|
|||
|
||||
from flask import (abort, redirect, request, url_for, render_template,
|
||||
make_response, Response)
|
||||
from flask.ext.login import login_user, UserMixin
|
||||
from flask.ext.principal import identity_changed
|
||||
from flask.ext.login import login_required, current_user
|
||||
from urlparse import urlparse
|
||||
|
||||
from data import model
|
||||
from app import app, login_manager, mixpanel
|
||||
from auth.permissions import (QuayDeferredPermissionUser,
|
||||
AdministerOrganizationPermission)
|
||||
from app import app, mixpanel
|
||||
from auth.permissions import AdministerOrganizationPermission
|
||||
from util.invoice import renderInvoiceToPdf
|
||||
from util.seo import render_snapshot
|
||||
from util.cache import no_cache
|
||||
from endpoints.api import get_route_data
|
||||
from endpoints.common import common_login
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _LoginWrappedDBUser(UserMixin):
|
||||
def __init__(self, db_username, db_user=None):
|
||||
|
||||
self._db_username = db_username
|
||||
self._db_user = db_user
|
||||
|
||||
def db_user(self):
|
||||
if not self._db_user:
|
||||
self._db_user = model.get_user(self._db_username)
|
||||
return self._db_user
|
||||
|
||||
def is_authenticated(self):
|
||||
return self.db_user() is not None
|
||||
|
||||
def is_active(self):
|
||||
return self.db_user().verified
|
||||
|
||||
def get_id(self):
|
||||
return unicode(self._db_username)
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(username):
|
||||
logger.debug('Loading user: %s' % username)
|
||||
return _LoginWrappedDBUser(username)
|
||||
def render_page_template(name, **kwargs):
|
||||
return make_response(render_template(name, route_data=get_route_data(),
|
||||
**kwargs))
|
||||
|
||||
|
||||
@app.route('/', methods=['GET'], defaults={'path': ''})
|
||||
@app.route('/repository/<path:path>', methods=['GET'])
|
||||
@app.route('/organization/<path:path>', methods=['GET'])
|
||||
@no_cache
|
||||
def index(path):
|
||||
return render_template('index.html')
|
||||
return render_page_template('index.html')
|
||||
|
||||
|
||||
@app.route('/snapshot', methods=['GET'])
|
||||
|
@ -67,26 +47,32 @@ def snapshot(path = ''):
|
|||
|
||||
|
||||
@app.route('/plans/')
|
||||
@no_cache
|
||||
def plans():
|
||||
return index('')
|
||||
|
||||
|
||||
@app.route('/guide/')
|
||||
@no_cache
|
||||
def guide():
|
||||
return index('')
|
||||
|
||||
|
||||
@app.route('/organizations/')
|
||||
@app.route('/organizations/new/')
|
||||
@no_cache
|
||||
def organizations():
|
||||
return index('')
|
||||
|
||||
|
||||
@app.route('/user/')
|
||||
@no_cache
|
||||
def user():
|
||||
return index('')
|
||||
|
||||
|
||||
@app.route('/signin/')
|
||||
@no_cache
|
||||
def signin():
|
||||
return index('')
|
||||
|
||||
|
@ -97,76 +83,85 @@ def contact():
|
|||
|
||||
|
||||
@app.route('/new/')
|
||||
@no_cache
|
||||
def new():
|
||||
return index('')
|
||||
|
||||
|
||||
@app.route('/repository/')
|
||||
@no_cache
|
||||
def repository():
|
||||
return index('')
|
||||
|
||||
|
||||
@app.route('/security/')
|
||||
@no_cache
|
||||
def security():
|
||||
return index('')
|
||||
|
||||
|
||||
@app.route('/v1')
|
||||
@app.route('/v1/')
|
||||
@no_cache
|
||||
def v1():
|
||||
return index('')
|
||||
|
||||
|
||||
@app.route('/status', methods=['GET'])
|
||||
@no_cache
|
||||
def status():
|
||||
return make_response('Healthy')
|
||||
|
||||
|
||||
@app.route('/tos', methods=['GET'])
|
||||
@no_cache
|
||||
def tos():
|
||||
return render_template('tos.html')
|
||||
return render_page_template('tos.html')
|
||||
|
||||
|
||||
@app.route('/disclaimer', methods=['GET'])
|
||||
@no_cache
|
||||
def disclaimer():
|
||||
return render_template('disclaimer.html')
|
||||
return render_page_template('disclaimer.html')
|
||||
|
||||
|
||||
@app.route('/privacy', methods=['GET'])
|
||||
@no_cache
|
||||
def privacy():
|
||||
return render_template('privacy.html')
|
||||
return render_page_template('privacy.html')
|
||||
|
||||
|
||||
@app.route('/receipt', methods=['GET'])
|
||||
def receipt():
|
||||
if not current_user.is_authenticated():
|
||||
abort(401)
|
||||
return
|
||||
|
||||
id = request.args.get('id')
|
||||
if id:
|
||||
invoice = stripe.Invoice.retrieve(id)
|
||||
if invoice:
|
||||
org = model.get_user_or_org_by_customer_id(invoice.customer)
|
||||
if org and org.organization:
|
||||
admin_org = AdministerOrganizationPermission(org.username)
|
||||
if admin_org.can():
|
||||
file_data = renderInvoiceToPdf(invoice, org)
|
||||
return Response(file_data,
|
||||
mimetype="application/pdf",
|
||||
headers={"Content-Disposition":
|
||||
"attachment;filename=receipt.pdf"})
|
||||
user_or_org = model.get_user_or_org_by_customer_id(invoice.customer)
|
||||
|
||||
if user_or_org:
|
||||
if user_or_org.organization:
|
||||
admin_org = AdministerOrganizationPermission(user_or_org.username)
|
||||
if not admin_org.can():
|
||||
abort(404)
|
||||
return
|
||||
else:
|
||||
if not user_or_org.username == current_user.db_user().username:
|
||||
abort(404)
|
||||
return
|
||||
|
||||
file_data = renderInvoiceToPdf(invoice, user_or_org)
|
||||
return Response(file_data,
|
||||
mimetype="application/pdf",
|
||||
headers={"Content-Disposition": "attachment;filename=receipt.pdf"})
|
||||
abort(404)
|
||||
|
||||
def common_login(db_user):
|
||||
if login_user(_LoginWrappedDBUser(db_user.username, db_user)):
|
||||
logger.debug('Successfully signed in as: %s' % db_user.username)
|
||||
new_identity = QuayDeferredPermissionUser(db_user.username, 'username')
|
||||
identity_changed.send(app, identity=new_identity)
|
||||
return True
|
||||
else:
|
||||
logger.debug('User could not be logged in, inactive?.')
|
||||
return False
|
||||
|
||||
|
||||
@app.route('/oauth2/github/callback', methods=['GET'])
|
||||
def github_oauth_callback():
|
||||
def exchange_github_code_for_token(code):
|
||||
code = request.args.get('code')
|
||||
payload = {
|
||||
'client_id': app.config['GITHUB_CLIENT_ID'],
|
||||
|
@ -181,19 +176,37 @@ def github_oauth_callback():
|
|||
params=payload, headers=headers)
|
||||
|
||||
token = get_access_token.json()['access_token']
|
||||
return token
|
||||
|
||||
|
||||
def get_github_user(token):
|
||||
token_param = {
|
||||
'access_token': token,
|
||||
}
|
||||
get_user = requests.get(app.config['GITHUB_USER_URL'], params=token_param)
|
||||
|
||||
user_data = get_user.json()
|
||||
return get_user.json()
|
||||
|
||||
|
||||
@app.route('/oauth2/github/callback', methods=['GET'])
|
||||
def github_oauth_callback():
|
||||
error = request.args.get('error', None)
|
||||
if error:
|
||||
return render_page_template('githuberror.html', error_message=error)
|
||||
|
||||
token = exchange_github_code_for_token(request.args.get('code'))
|
||||
user_data = get_github_user(token)
|
||||
|
||||
username = user_data['login']
|
||||
github_id = user_data['id']
|
||||
|
||||
v3_media_type = {
|
||||
'Accept': 'application/vnd.github.v3'
|
||||
}
|
||||
|
||||
token_param = {
|
||||
'access_token': token,
|
||||
}
|
||||
get_email = requests.get(app.config['GITHUB_USER_EMAILS'],
|
||||
params=token_param, headers=v3_media_type)
|
||||
|
||||
|
@ -220,18 +233,33 @@ def github_oauth_callback():
|
|||
mixpanel.alias(to_login.username, state)
|
||||
|
||||
except model.DataModelException, ex:
|
||||
return render_template('githuberror.html', error_message=ex.message)
|
||||
return render_page_template('githuberror.html', error_message=ex.message)
|
||||
|
||||
if common_login(to_login):
|
||||
return redirect(url_for('index'))
|
||||
|
||||
return render_template('githuberror.html')
|
||||
return render_page_template('githuberror.html')
|
||||
|
||||
|
||||
@app.route('/oauth2/github/callback/attach', methods=['GET'])
|
||||
@login_required
|
||||
def github_oauth_attach():
|
||||
token = exchange_github_code_for_token(request.args.get('code'))
|
||||
user_data = get_github_user(token)
|
||||
github_id = user_data['id']
|
||||
user_obj = current_user.db_user()
|
||||
model.attach_federated_login(user_obj, 'github', github_id)
|
||||
return redirect(url_for('user'))
|
||||
|
||||
|
||||
@app.route('/confirm', methods=['GET'])
|
||||
def confirm_email():
|
||||
code = request.values['code']
|
||||
user = model.confirm_user_email(code)
|
||||
|
||||
try:
|
||||
user = model.confirm_user_email(code)
|
||||
except model.DataModelException as ex:
|
||||
return redirect(url_for('signin'))
|
||||
|
||||
common_login(user)
|
||||
|
||||
|
@ -248,8 +276,3 @@ def confirm_recovery():
|
|||
return redirect(url_for('user'))
|
||||
else:
|
||||
abort(403)
|
||||
|
||||
|
||||
@app.route('/reset', methods=['GET'])
|
||||
def password_reset():
|
||||
pass
|
||||
|
|
|
@ -1,19 +1,14 @@
|
|||
import logging
|
||||
import requests
|
||||
import stripe
|
||||
|
||||
from flask import (abort, redirect, request, url_for, render_template,
|
||||
make_response)
|
||||
from flask.ext.login import login_user, UserMixin, login_required
|
||||
from flask.ext.principal import identity_changed, Identity, AnonymousIdentity
|
||||
from flask import request, make_response
|
||||
|
||||
from data import model
|
||||
from app import app, login_manager, mixpanel
|
||||
from auth.permissions import QuayDeferredPermissionUser
|
||||
from data.plans import USER_PLANS, BUSINESS_PLANS, get_plan
|
||||
from app import app
|
||||
from util.invoice import renderInvoiceToHtml
|
||||
from util.email import send_invoice_email
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -33,10 +28,10 @@ def stripe_webhook():
|
|||
# Find the user associated with the customer ID.
|
||||
user = model.get_user_or_org_by_customer_id(customer_id)
|
||||
if user and user.invoice_email:
|
||||
# Lookup the invoice.
|
||||
invoice = stripe.Invoice.retrieve(invoice_id)
|
||||
if invoice:
|
||||
invoice_html = renderInvoiceToHtml(invoice, user)
|
||||
send_invoice_email(user.email, invoice_html)
|
||||
# Lookup the invoice.
|
||||
invoice = stripe.Invoice.retrieve(invoice_id)
|
||||
if invoice:
|
||||
invoice_html = renderInvoiceToHtml(invoice, user)
|
||||
send_invoice_email(user.email, invoice_html)
|
||||
|
||||
return make_response('Okay')
|
||||
|
|
5
gunicorn_config.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
bind = 'unix:/tmp/gunicorn.sock'
|
||||
workers = 8
|
||||
worker_class = 'gevent'
|
||||
timeout = 2000
|
||||
daemon = True
|
30
initdb.py
|
@ -1,11 +1,9 @@
|
|||
import logging
|
||||
import string
|
||||
import shutil
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import random
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import url_for
|
||||
from peewee import SqliteDatabase, create_model_tables, drop_model_tables
|
||||
|
||||
from data.database import *
|
||||
|
@ -18,6 +16,15 @@ store = app.config['STORAGE']
|
|||
|
||||
SAMPLE_DIFFS = ['test/data/sample/diffs/diffs%s.json' % i
|
||||
for i in range(1, 10)]
|
||||
SAMPLE_CMDS = [["/bin/bash"],
|
||||
["/bin/sh", "-c",
|
||||
"echo \"PasswordAuthentication no\" >> /etc/ssh/sshd_config"],
|
||||
["/bin/sh", "-c",
|
||||
"sed -i 's/#\\(force_color_prompt\\)/\\1/' /etc/skel/.bashrc"],
|
||||
["/bin/sh", "-c", "#(nop) EXPOSE [8080]"],
|
||||
["/bin/sh", "-c",
|
||||
"#(nop) MAINTAINER Jake Moshenko <jake@devtable.com>"],
|
||||
None]
|
||||
|
||||
REFERENCE_DATE = datetime(2013, 6, 23)
|
||||
TEST_STRIPE_ID = 'cus_2tmnh3PkXQS8NG'
|
||||
|
@ -51,9 +58,14 @@ def __create_subtree(repo, structure, parent):
|
|||
model.set_image_checksum(docker_image_id, repo, checksum)
|
||||
|
||||
creation_time = REFERENCE_DATE + timedelta(days=image_num)
|
||||
command_list = SAMPLE_CMDS[image_num % len(SAMPLE_CMDS)]
|
||||
command = json.dumps(command_list) if command_list else None
|
||||
new_image = model.set_image_metadata(docker_image_id, repo.namespace,
|
||||
repo.name, str(creation_time),
|
||||
'no comment', parent)
|
||||
'no comment', command, parent)
|
||||
|
||||
model.set_image_size(docker_image_id, repo.namespace, repo.name,
|
||||
random.randrange(1, 1024 * 1024 * 1024))
|
||||
|
||||
# Populate the diff file
|
||||
diff_path = store.image_file_diffs_path(repo.namespace, repo.name,
|
||||
|
@ -123,6 +135,7 @@ def initialize_database():
|
|||
LogEntryKind.create(name='push_repo')
|
||||
LogEntryKind.create(name='pull_repo')
|
||||
LogEntryKind.create(name='delete_repo')
|
||||
LogEntryKind.create(name='delete_tag')
|
||||
LogEntryKind.create(name='add_repo_permission')
|
||||
LogEntryKind.create(name='change_repo_permission')
|
||||
LogEntryKind.create(name='delete_repo_permission')
|
||||
|
@ -160,6 +173,7 @@ def populate_database():
|
|||
new_user_1 = model.create_user('devtable', 'password',
|
||||
'jschorr@devtable.com')
|
||||
new_user_1.verified = True
|
||||
new_user_1.stripe_id = TEST_STRIPE_ID
|
||||
new_user_1.save()
|
||||
|
||||
model.create_robot('dtrobot', new_user_1)
|
||||
|
@ -279,6 +293,12 @@ def populate_database():
|
|||
model.log_action('pull_repo', org.username, repository=org_repo, timestamp=today,
|
||||
metadata={'token': 'sometoken', 'token_code': 'somecode', 'repo': 'orgrepo'})
|
||||
|
||||
model.log_action('delete_tag', org.username, performer=new_user_2, repository=org_repo, timestamp=today,
|
||||
metadata={'username': new_user_2.username, 'repo': 'orgrepo', 'tag': 'sometag'})
|
||||
|
||||
model.log_action('pull_repo', org.username, repository=org_repo, timestamp=today,
|
||||
metadata={'token_code': 'somecode', 'repo': 'orgrepo'})
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(**app.config['LOGGING_CONFIG'])
|
||||
initialize_database()
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
worker_processes 1;
|
||||
worker_processes 2;
|
||||
|
||||
user root nogroup;
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /tmp/nginx.error.log;
|
||||
pid /mnt/nginx/nginx.pid;
|
||||
error_log /mnt/nginx/nginx.error.log;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
|
@ -11,10 +11,10 @@ events {
|
|||
|
||||
http {
|
||||
types_hash_max_size 2048;
|
||||
include /etc/nginx/mime.types;
|
||||
include /usr/local/nginx/conf/mime.types.default;
|
||||
|
||||
default_type application/octet-stream;
|
||||
access_log /tmp/nginx.access.log combined;
|
||||
access_log /mnt/nginx/nginx.access.log combined;
|
||||
sendfile on;
|
||||
|
||||
root /root/quay/;
|
||||
|
@ -43,6 +43,7 @@ http {
|
|||
server {
|
||||
listen 443 default;
|
||||
client_max_body_size 8G;
|
||||
client_body_temp_path /mnt/nginx/client_body 1 2;
|
||||
server_name _;
|
||||
|
||||
keepalive_timeout 5;
|
||||
|
@ -71,8 +72,12 @@ http {
|
|||
proxy_redirect off;
|
||||
proxy_buffering off;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_set_header Transfer-Encoding $http_transfer_encoding;
|
||||
|
||||
proxy_pass http://app_server;
|
||||
proxy_read_timeout 2000;
|
||||
proxy_temp_path /mnt/nginx/proxy_temp 1 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
15
nginx.conf
|
@ -1,8 +1,8 @@
|
|||
worker_processes 1;
|
||||
worker_processes 8;
|
||||
|
||||
user nobody nogroup;
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /tmp/nginx.error.log;
|
||||
pid /mnt/nginx/nginx.pid;
|
||||
error_log /mnt/nginx/nginx.error.log;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
|
@ -11,10 +11,10 @@ events {
|
|||
|
||||
http {
|
||||
types_hash_max_size 2048;
|
||||
include /etc/nginx/mime.types;
|
||||
include /usr/local/nginx/conf/mime.types.default;
|
||||
|
||||
default_type application/octet-stream;
|
||||
access_log /tmp/nginx.access.log combined;
|
||||
access_log /mnt/nginx/nginx.access.log combined;
|
||||
sendfile on;
|
||||
|
||||
gzip on;
|
||||
|
@ -41,6 +41,7 @@ http {
|
|||
server {
|
||||
listen 443 default;
|
||||
client_max_body_size 8G;
|
||||
client_body_temp_path /mnt/nginx/client_body 1 2;
|
||||
server_name _;
|
||||
|
||||
keepalive_timeout 5;
|
||||
|
@ -69,8 +70,12 @@ http {
|
|||
proxy_redirect off;
|
||||
proxy_buffering off;
|
||||
|
||||
proxy_request_buffering off;
|
||||
proxy_set_header Transfer-Encoding $http_transfer_encoding;
|
||||
|
||||
proxy_pass http://app_server;
|
||||
proxy_read_timeout 2000;
|
||||
proxy_temp_path /mnt/nginx/proxy_temp 1 2;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,20 +1,20 @@
|
|||
APScheduler==2.1.1
|
||||
Flask==0.10.1
|
||||
Flask-Login==0.2.7
|
||||
Flask-Login==0.2.9
|
||||
Flask-Mail==0.9.0
|
||||
Flask-Principal==0.4.0
|
||||
Jinja2==2.7.1
|
||||
MarkupSafe==0.18
|
||||
Pillow==2.2.1
|
||||
Pillow==2.3.0
|
||||
PyMySQL==0.6.1
|
||||
Werkzeug==0.9.4
|
||||
argparse==1.2.1
|
||||
beautifulsoup4==4.3.2
|
||||
blinker==1.3
|
||||
boto==2.17.0
|
||||
boto==2.21.2
|
||||
distribute==0.6.34
|
||||
ecdsa==0.10
|
||||
gevent==0.13.8
|
||||
gevent==1.0
|
||||
greenlet==0.4.1
|
||||
gunicorn==18.0
|
||||
html5lib==1.0b3
|
||||
|
@ -23,16 +23,16 @@ lockfile==0.9.1
|
|||
marisa-trie==0.5.1
|
||||
mixpanel-py==3.0.0
|
||||
paramiko==1.12.0
|
||||
peewee==2.1.5
|
||||
peewee==2.1.7
|
||||
py-bcrypt==0.4
|
||||
pyPdf==1.13
|
||||
pycrypto==2.6.1
|
||||
python-daemon==1.6
|
||||
python-dateutil==2.2
|
||||
python-digitalocean==0.5.1
|
||||
python-digitalocean==0.6
|
||||
reportlab==2.7
|
||||
requests==2.0.1
|
||||
requests==2.1.0
|
||||
six==1.4.1
|
||||
stripe==1.9.8
|
||||
stripe==1.11.0
|
||||
wsgiref==0.1.2
|
||||
xhtml2pdf==0.0.5
|
||||
|
|
|
@ -10,20 +10,6 @@ var casper = require('casper').create({
|
|||
logLevel: "debug"
|
||||
});
|
||||
|
||||
var disableOlark = function() {
|
||||
casper.then(function() {
|
||||
this.waitForText('Chat with us!', function() {
|
||||
this.evaluate(function() {
|
||||
console.log(olark);
|
||||
window.olark.configure('box.start_hidden', true);
|
||||
window.olark('api.box.hide');
|
||||
});
|
||||
}, function() {
|
||||
// Do nothing, if olark never loaded we're ok with that
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var options = casper.cli.options;
|
||||
var isDebug = !!options['d'];
|
||||
|
||||
|
@ -56,12 +42,18 @@ casper.thenClick('.form-signin button[type=submit]', function() {
|
|||
this.waitForText('Top Repositories');
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
casper.then(function() {
|
||||
this.log('Generating user home screenshot.');
|
||||
});
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'user-home.png');
|
||||
});
|
||||
|
||||
casper.then(function() {
|
||||
this.log('Generating repository view screenshot.');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'repository/devtable/' + repo + '?tag=v2.0', function() {
|
||||
// Wait for the tree to initialize.
|
||||
this.waitForSelector('.image-tree', function() {
|
||||
|
@ -70,12 +62,14 @@ casper.thenOpen(rootUrl + 'repository/devtable/' + repo + '?tag=v2.0', function(
|
|||
});
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'repo-view.png');
|
||||
});
|
||||
|
||||
casper.then(function() {
|
||||
this.log('Generating repository changes screenshot.');
|
||||
});
|
||||
|
||||
casper.thenClick('#current-image dd a', function() {
|
||||
this.waitForSelector('.result-count', function() {
|
||||
this.capture(outputDir + 'repo-changes.png', {
|
||||
|
@ -87,58 +81,79 @@ casper.thenClick('#current-image dd a', function() {
|
|||
});
|
||||
})
|
||||
|
||||
casper.then(function() {
|
||||
this.log('Generating repository admin screenshot.');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'repository/devtable/' + repo + '/admin', function() {
|
||||
this.waitForSelector('.repo-access-state');
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'repo-admin.png');
|
||||
});
|
||||
|
||||
casper.then(function() {
|
||||
this.log('Generating organization repo list screenshot.');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'repository/?namespace=' + org, function() {
|
||||
this.waitForText('Repositories');
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'org-repo-list.png');
|
||||
});
|
||||
|
||||
casper.then(function() {
|
||||
this.log('Generating organization teams screenshot.');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'organization/' + org, function() {
|
||||
this.waitForSelector('.organization-name');
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'org-teams.png');
|
||||
});
|
||||
|
||||
casper.then(function() {
|
||||
this.log('Generating organization admin screenshot.');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'organization/' + org + '/admin', function() {
|
||||
this.waitForSelector('#repository-usage-chart');
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'org-admin.png');
|
||||
});
|
||||
|
||||
casper.then(function() {
|
||||
this.log('Generating organization logs screenshot.');
|
||||
});
|
||||
|
||||
casper.thenClick('a[data-target="#logs"]', function() {
|
||||
this.waitForSelector('svg > g', function() {
|
||||
this.capture(outputDir + 'org-logs.png');
|
||||
this.wait(1000, function() {
|
||||
this.capture(outputDir + 'org-logs.png', {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: width,
|
||||
height: height + 200
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
casper.then(function() {
|
||||
this.log('Generating oganization repository admin screenshot.');
|
||||
});
|
||||
|
||||
casper.thenOpen(rootUrl + 'repository/' + org + '/' + orgrepo + '/admin', function() {
|
||||
this.waitForText('outsideorg')
|
||||
});
|
||||
|
||||
disableOlark();
|
||||
|
||||
casper.then(function() {
|
||||
this.capture(outputDir + 'org-repo-admin.png');
|
||||
});
|
||||
|
|
|
@ -3,6 +3,76 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 410px) {
|
||||
.olrk-normal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.codetooltipcontainer .tooltip-inner {
|
||||
white-space:pre;
|
||||
max-width:none;
|
||||
}
|
||||
|
||||
.codetooltip {
|
||||
font-family: Consolas, "Lucida Console", Monaco, monospace;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.resource-view-element {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.resource-view-element .resource-spinner {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0s ease-in-out;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resource-view-element .resource-content {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.resource-view-element .resource-content.visible {
|
||||
z-index: 2;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.resource-view-element .resource-error {
|
||||
margin: 10px;
|
||||
font-size: 16px;
|
||||
color: #444;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resource-view-element .resource-spinner.visible {
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease-in-out;
|
||||
}
|
||||
|
||||
.small-spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: solid 2px transparent;
|
||||
border-top-color: #444;
|
||||
border-left-color: #444;
|
||||
border-radius: 10px;
|
||||
-webkit-animation: loading-bar-spinner 400ms linear infinite;
|
||||
-moz-animation: loading-bar-spinner 400ms linear infinite;
|
||||
-ms-animation: loading-bar-spinner 400ms linear infinite;
|
||||
-o-animation: loading-bar-spinner 400ms linear infinite;
|
||||
animation: loading-bar-spinner 400ms linear infinite;
|
||||
}
|
||||
|
||||
#loading-bar-spinner {
|
||||
top: 70px;
|
||||
}
|
||||
|
||||
.entity-search-element input {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
@ -48,10 +118,7 @@ html, body {
|
|||
.tooltip {
|
||||
word-break: normal !important;
|
||||
word-wrap: normal !important;
|
||||
}
|
||||
|
||||
.code-info {
|
||||
border-bottom: 1px dashed #aaa;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
|
@ -136,12 +203,11 @@ i.toggle-icon:hover {
|
|||
min-height: 100%;
|
||||
height: auto !important;
|
||||
height: 100%;
|
||||
margin: 0 auto -136px;
|
||||
margin: 0 auto -176px;
|
||||
}
|
||||
|
||||
.footer-container, .push {
|
||||
height: 110px;
|
||||
overflow: hidden;
|
||||
height: 74px;
|
||||
}
|
||||
|
||||
.footer-container.fixed {
|
||||
|
@ -193,6 +259,10 @@ i.toggle-icon:hover {
|
|||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.logs-view-element .logs-date-picker {
|
||||
width: 122px;
|
||||
}
|
||||
|
||||
.logs-view-element .header input {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
|
@ -608,49 +678,165 @@ i.toggle-icon:hover {
|
|||
|
||||
.plans-list .plan {
|
||||
vertical-align: top;
|
||||
|
||||
padding: 10px;
|
||||
border: 1px solid #eee;
|
||||
border-top: 4px solid #94C9F7;
|
||||
font-size: 1.4em;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.plans-list .plan.small {
|
||||
border: 1px solid #ddd;
|
||||
border-top: 4px solid #428bca;
|
||||
margin-top: 0px;
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
.plans-list .plan.business-plan {
|
||||
border: 1px solid #eee;
|
||||
border-top: 4px solid #94F794;
|
||||
}
|
||||
|
||||
.plans-list .plan.bus-small {
|
||||
border: 1px solid #ddd;
|
||||
border-top: 4px solid #47A447;
|
||||
margin-top: 0px;
|
||||
font-size: 1.6em;
|
||||
border-top: 6px solid #46ac39;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.plans-list .plan.bus-small .plan-box {
|
||||
background: black !important;
|
||||
}
|
||||
|
||||
.plans-list .plan:last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.plans-list .plan .plan-box {
|
||||
background: #444;
|
||||
padding: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.plans-list .plan .plan-title {
|
||||
text-transform: uppercase;
|
||||
padding-top: 25px;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.visible-sm-inline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hidden-sm-inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) and (min-width: 768px) {
|
||||
.visible-sm-inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.hidden-sm-inline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.plans-list .plan-box .description {
|
||||
color: white;
|
||||
margin-top: 6px;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.plans-list .plan button {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.plans-list .plan.bus-small button {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.plans-list .features-bar {
|
||||
padding-top: 248px;
|
||||
}
|
||||
|
||||
.plans-list .features-bar .feature .count {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.plans-list .features-bar .feature {
|
||||
height: 43px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-tooltip {
|
||||
border-bottom: 1px dotted black;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.plans-list .features-bar .feature i {
|
||||
margin-left: 16px;
|
||||
float: right;
|
||||
width: 16px;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.plans-list .plan .features {
|
||||
padding: 6px;
|
||||
background: #eee;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.plans-list .plan .feature {
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.plans-list .plan .feature:after {
|
||||
content: "";
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.plans-list .plan .visible-xs .feature {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.plans-list .plan .visible-xs .feature:after {
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.plans-list .plan .feature.notpresent {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.plans-list .plan .feature.present:after {
|
||||
background: #428bca;
|
||||
}
|
||||
|
||||
.plans-list .plan.business-plan .feature.present:after {
|
||||
background: #46ac39;
|
||||
}
|
||||
|
||||
.plans-list .plan .count, .plans-list .features-bar .count {
|
||||
background: white;
|
||||
border-bottom: 0px;
|
||||
text-align: center !important;
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.plans-list .plan .count b, .plans-list .features-bar .count b {
|
||||
font-size: 1.5em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plans-list .plan .feature:last-child {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.plans-list .plan-price {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.plan-price {
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
font-size: 1.8em;
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
@ -678,7 +864,8 @@ i.toggle-icon:hover {
|
|||
.plans-list .plan .description {
|
||||
font-size: 1em;
|
||||
font-size: 16px;
|
||||
margin-bottom: 10px;
|
||||
height: 34px;
|
||||
|
||||
}
|
||||
|
||||
.plans-list .plan .smaller {
|
||||
|
@ -829,12 +1016,20 @@ form input.ng-valid.ng-dirty,
|
|||
}
|
||||
|
||||
.page-footer {
|
||||
padding: 10px;
|
||||
padding-bottom: 0px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.page-footer-padder {
|
||||
margin-top: 76px;
|
||||
background-color: white;
|
||||
background-image: none;
|
||||
padding: 10px;
|
||||
padding-bottom: 40px;
|
||||
margin-top: 52px;
|
||||
border-top: 1px solid #eee;
|
||||
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.page-footer .row {
|
||||
|
@ -909,12 +1104,20 @@ form input.ng-valid.ng-dirty,
|
|||
.entity-mini-listing {
|
||||
margin: 2px;
|
||||
white-space: nowrap !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.entity-mini-listing i {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.entity-mini-listing i.fa-exclamation-triangle {
|
||||
position: absolute;
|
||||
right: -16px;
|
||||
top: 4px;
|
||||
color: #c09853;
|
||||
}
|
||||
|
||||
.entity-mini-listing .warning {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
|
@ -967,20 +1170,84 @@ p.editable:hover i {
|
|||
font-size: 1.15em;
|
||||
}
|
||||
|
||||
#tagContextMenu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 100000;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#tagContextMenu ul {
|
||||
display: block;
|
||||
position: static;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.tag-controls {
|
||||
display: inline-block;
|
||||
margin-right: 20px;
|
||||
margin-top: 2px;
|
||||
opacity: 0;
|
||||
float: right;
|
||||
-webkit-transition: opacity 200ms ease-in-out;
|
||||
-moz-transition: opacity 200ms ease-in-out;
|
||||
transition: opacity 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.right-title {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.tag-dropdown .tag-count {
|
||||
.right-tag-controls {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
padding: 4px;
|
||||
padding-left: 10px;
|
||||
border-left: 1px solid #ccc;
|
||||
vertical-align: middle;
|
||||
margin-top: -2px;
|
||||
margin-right: -10px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.right-tag-controls .tag-count {
|
||||
display: inline-block;
|
||||
margin-left: 4px;
|
||||
margin-right: 6px;
|
||||
padding-right: 10px;
|
||||
border-right: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.tag-image-sizes .tag-image-size {
|
||||
height: 22px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tag-image-sizes .tag-image-size .size-title {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag-image-sizes .tag-image-size .size-limiter {
|
||||
display: inline-block;
|
||||
padding-right: 90px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tag-image-sizes .tag-image-size .size-bar {
|
||||
display: inline-block;
|
||||
background: steelblue;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
#current-tag .control-bar {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #eee;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tag-dropdown a {
|
||||
|
@ -993,6 +1260,61 @@ p.editable:hover i {
|
|||
border: 0px;
|
||||
}
|
||||
|
||||
#confirmdeleteTagModal .image-listings {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
#confirmdeleteTagModal .image-listings .image-listing {
|
||||
margin: 4px;
|
||||
padding: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#confirmdeleteTagModal .image-listings .image-listing .image-listing-id {
|
||||
display: inline-block;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
#confirmdeleteTagModal .image-listings .image-listing .image-listing-line {
|
||||
border-left: 2px solid steelblue;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
bottom: 8px;
|
||||
left: 6px;
|
||||
width: 1px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#confirmdeleteTagModal .image-listings .image-listing.tag-image .image-listing-line {
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
#confirmdeleteTagModal .image-listings .image-listing.child .image-listing-line {
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
#confirmdeleteTagModal .image-listings .image-listing .image-listing-circle {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
||||
border-radius: 50%;
|
||||
border: 2px solid steelblue;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: inline-block;
|
||||
background: white;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#confirmdeleteTagModal .image-listings .image-listing.tag-image .image-listing-circle {
|
||||
background: steelblue;
|
||||
}
|
||||
|
||||
#confirmdeleteTagModal .more-changes {
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.repo .header {
|
||||
margin-bottom: 10px;
|
||||
position: relative;
|
||||
|
@ -1009,7 +1331,7 @@ p.editable:hover i {
|
|||
|
||||
.repo .description {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 40px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.repo .empty-message {
|
||||
|
@ -1343,6 +1665,18 @@ p.editable:hover i {
|
|||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.repo .formatted-command {
|
||||
margin-top: 4px;
|
||||
padding: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.repo .formatted-command.trimmed {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.repo .changes-count-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -1567,6 +1901,10 @@ p.editable:hover i {
|
|||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.user-admin .check-green {
|
||||
color: #46ac39;
|
||||
}
|
||||
|
||||
#image-history-container {
|
||||
overflow: hidden;
|
||||
min-height: 400px;
|
||||
|
@ -1617,7 +1955,7 @@ p.editable:hover i {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
#image-history-container .tags .tag {
|
||||
#image-history-container .tags .tag, #confirmdeleteTagModal .tag {
|
||||
border-radius: 10px;
|
||||
margin-right: 4px;
|
||||
cursor: pointer;
|
||||
|
@ -1864,28 +2202,28 @@ p.editable:hover i {
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
.org-admin .invoice-title {
|
||||
.billing-invoices-element .invoice-title {
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.org-admin .invoice-status .success {
|
||||
.billing-invoices-element .invoice-status .success {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.org-admin .invoice-status .pending {
|
||||
.billing-invoices-element .invoice-status .pending {
|
||||
color: steelblue;
|
||||
}
|
||||
|
||||
.org-admin .invoice-status .danger {
|
||||
.billing-invoices-element .invoice-status .danger {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.org-admin .invoice-amount:before {
|
||||
.billing-invoices-element .invoice-amount:before {
|
||||
content: '$';
|
||||
}
|
||||
|
||||
.org-admin .invoice-details {
|
||||
.billing-invoices-element .invoice-details {
|
||||
margin-left: 10px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
|
@ -1894,21 +2232,21 @@ p.editable:hover i {
|
|||
border-left: 2px solid #eee !important;
|
||||
}
|
||||
|
||||
.org-admin .invoice-details td {
|
||||
.billing-invoices-element .invoice-details td {
|
||||
border: 0px solid transparent !important;
|
||||
}
|
||||
|
||||
.org-admin .invoice-details dl {
|
||||
.billing-invoices-element .invoice-details dl {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.org-admin .invoice-details dd {
|
||||
.billing-invoices-element .invoice-details dd {
|
||||
margin-left: 10px;
|
||||
padding: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.org-admin .invoice-title:hover {
|
||||
.billing-invoices-element .invoice-title:hover {
|
||||
color: steelblue;
|
||||
}
|
||||
|
||||
|
@ -2034,6 +2372,14 @@ p.editable:hover i {
|
|||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.plan-manager-element .plans-list-table .deprecated-plan {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.plan-manager-element .plans-list-table .deprecated-plan-label {
|
||||
font-size: 0.7em;
|
||||
}
|
||||
|
||||
.plans-table-element table {
|
||||
margin: 20px;
|
||||
border: 1px solid #eee;
|
||||
|
@ -2179,16 +2525,37 @@ p.editable:hover i {
|
|||
display: block;
|
||||
}
|
||||
|
||||
.d3-tip .created {
|
||||
.d3-tip .command {
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-family: Consolas, "Lucida Console", Monaco, monospace;
|
||||
}
|
||||
|
||||
.d3-tip .info-line {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.d3-tip .info-line i {
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.d3-tip .comment {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px dotted #ccc;
|
||||
}
|
||||
|
||||
.d3-tip .created {
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
|
|
53
static/directives/billing-invoices.html
Normal 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>
|
|
@ -5,9 +5,9 @@
|
|||
Credit Card
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<i class="fa fa-spinner fa-spin fa-2x" ng-show="!currentCard || changingCard"></i>
|
||||
<div class="quay-spinner" ng-show="!currentCard || changingCard"></div>
|
||||
<div class="current-card" ng-show="currentCard && !changingCard">
|
||||
<img src="{{ '/static/img/creditcards/' + getCreditImage(currentCard) }}" ng-show="currentCard.last4">
|
||||
<img ng-src="{{ '/static/img/creditcards/' + getCreditImage(currentCard) }}" ng-show="currentCard.last4">
|
||||
<span class="no-card-outline" ng-show="!currentCard.last4"></span>
|
||||
|
||||
<span class="last4" ng-show="currentCard.last4">****-****-****-<b>{{ currentCard.last4 }}</b></span>
|
||||
|
@ -24,7 +24,7 @@
|
|||
<div class="panel">
|
||||
<div class="panel-title">
|
||||
Billing Options
|
||||
<i class="fa fa-spinner fa-spin" ng-show="working"></i>
|
||||
<div class="quay-spinner" ng-show="working"></div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="settings-option">
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" role="menu" aria-labelledby="entityDropdownMenu">
|
||||
<li ng-show="lazyLoading"><i class="fa fa-spinner"></i></li>
|
||||
<li ng-show="lazyLoading" style="padding: 10px"><div class="quay-spinner"></div></li>
|
||||
|
||||
<li role="presentation" ng-repeat="team in teams" ng-show="!lazyLoading"
|
||||
ng-click="setEntity(team.name, 'team', false)">
|
||||
|
|
|
@ -38,14 +38,21 @@
|
|||
<a href="javascript:void(0)" class="dropdown-toggle user-dropdown" data-toggle="dropdown">
|
||||
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=32&d=identicon" />
|
||||
{{ user.username }}
|
||||
<span class="badge user-notification notification-animated" ng-show="user.askForPassword">1</span>
|
||||
<span class="badge user-notification notification-animated" ng-show="user.askForPassword || overPlan"
|
||||
bs-tooltip="(user.askForPassword ? 'A password is needed for this account<br>' : '') + (overPlan ? 'You are using more private repositories than your plan allows' : '')"
|
||||
data-placement="left"
|
||||
data-container="body">
|
||||
{{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }}
|
||||
</span>
|
||||
<b class="caret"></b>
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a href="/user/" target="{{ appLinkTarget() }}">
|
||||
Account Settings
|
||||
<span class="badge user-notification" ng-show="user.askForPassword">1</span>
|
||||
<span class="badge user-notification" ng-show="user.askForPassword || overPlan">
|
||||
{{ (user.askForPassword ? 1 : 0) + (overPlan ? 1 : 0) }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li><a ng-href="/organizations/" target="{{ appLinkTarget() }}">Organizations</a></li>
|
||||
|
|
8
static/directives/loading-status.html
Normal 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>
|
|
@ -6,9 +6,9 @@
|
|||
<span class="entity-reference" name="performer.username" isrobot="performer.is_robot" ng-show="performer"></span>
|
||||
<span id="logs-range" class="mini">
|
||||
From
|
||||
<input type="text" class="input-small" name="start" ng-model="logStartDate" data-date-format="mm/dd/yyyy" bs-datepicker/>
|
||||
<input type="text" class="logs-date-picker input-sm" name="start" ng-model="logStartDate" data-date-format="mm/dd/yyyy" bs-datepicker/>
|
||||
<span class="add-on">to</span>
|
||||
<input type="text" class="input-small" name="end" ng-model="logEndDate" data-date-format="mm/dd/yyyy" bs-datepicker/>
|
||||
<input type="text" class="logs-date-picker input-sm" name="end" ng-model="logEndDate" data-date-format="mm/dd/yyyy" bs-datepicker/>
|
||||
</span>
|
||||
</span>
|
||||
<span class="right">
|
||||
|
@ -21,7 +21,7 @@
|
|||
</div>
|
||||
|
||||
<div ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
<div class="quay-spinner 3x"></div>
|
||||
</div>
|
||||
<div ng-show="!loading">
|
||||
<div id="bar-chart" style="width: 800px; height: 500px;" ng-show="chartVisible">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<div class="plan-manager-element">
|
||||
<!-- Loading/Changing -->
|
||||
<i class="fa fa-spinner fa-spin fa-3x" ng-show="planLoading"></i>
|
||||
<div class="quay-spinner 3x" ng-show="planLoading"></div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<div class="alert alert-danger" ng-show="limit == 'over' && !planLoading">
|
||||
|
@ -32,26 +32,35 @@
|
|||
<td></td>
|
||||
</thead>
|
||||
|
||||
<tr ng-repeat="plan in plans" ng-class="(subscribedPlan.stripeId === plan.stripeId) ? getActiveSubClass() : ''">
|
||||
<td>{{ plan.title }}</td>
|
||||
<tr ng-repeat="plan in plans" ng-show="isPlanVisible(plan, subscribedPlan)"
|
||||
ng-class="{'active':(subscribedPlan.stripeId === plan.stripeId), 'deprecated-plan':plan.deprecated}">
|
||||
<td>
|
||||
{{ plan.title }}
|
||||
<div class="deprecated-plan-label" ng-show="plan.deprecated">
|
||||
<span class="context-tooltip" title="This plan has been discontinued. As a valued early adopter, you may continue to stay on this plan indefinitely." bs-tooltip="tooltip.title" data-placement="right">Discontinued Plan</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ plan.privateRepos }}</td>
|
||||
<td><div class="plan-price">${{ plan.price / 100 }}</div></td>
|
||||
<td class="controls">
|
||||
<div ng-switch='plan.stripeId'>
|
||||
<div ng-switch-when='bus-free'>
|
||||
<button class="btn button-hidden">Hidden!</button>
|
||||
<div ng-switch='plan.deprecated'>
|
||||
<div ng-switch-when='true'>
|
||||
<button class="btn btn-danger" ng-click="cancelSubscription()">
|
||||
<span class="quay-spinner" ng-show="planChanging"></span>
|
||||
<span ng-show="!planChanging">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
<div ng-switch-default>
|
||||
<button class="btn" ng-show="subscribedPlan.stripeId !== plan.stripeId"
|
||||
ng-class="subscribedPlan.price == 0 ? 'btn-primary' : 'btn-default'"
|
||||
ng-click="changeSubscription(plan.stripeId)">
|
||||
<i class="fa fa-spinner fa-spin" ng-show="planChanging"></i>
|
||||
<span class="quay-spinner" ng-show="planChanging"></span>
|
||||
<span ng-show="!planChanging && subscribedPlan.price != 0">Change</span>
|
||||
<span ng-show="!planChanging && subscribedPlan.price == 0">Subscribe</span>
|
||||
</button>
|
||||
<button class="btn btn-danger" ng-show="subscription.plan === plan.stripeId && plan.price > 0"
|
||||
ng-click="cancelSubscription()">
|
||||
<i class="fa fa-spinner fa-spin" ng-show="planChanging"></i>
|
||||
<span class="quay-spinner" ng-show="planChanging"></span>
|
||||
<span ng-show="!planChanging">Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -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>
|
||||
|
|
11
static/directives/resource-view.html
Normal 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>
|
|
@ -1,5 +1,5 @@
|
|||
<div class="robots-manager-element">
|
||||
<i class="fa fa-spinner fa-spin fa-3x" ng-show="loading"></i>
|
||||
<div class="quay-spinner" ng-show="loading"></div>
|
||||
<div class="alert alert-info">Robot accounts allow for delegating access in multiple repositories to role-based accounts that you manage</div>
|
||||
|
||||
<div class="container" ng-show="!loading">
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
</div>
|
||||
</form>
|
||||
<div ng-show="registering" style="text-align: center">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
<div class="quay-spinner 2x"></div>
|
||||
</div>
|
||||
<div ng-show="awaitingConfirmation">
|
||||
<div class="sub-message">
|
||||
|
|
1
static/directives/spinner.html
Normal file
|
@ -0,0 +1 @@
|
|||
<div class="small-spinner"></div>
|
|
@ -43,7 +43,7 @@
|
|||
<button class="btn btn-lg btn-primary btn-block" type="submit">Send Recovery Email</button>
|
||||
</form>
|
||||
|
||||
<div class="alert alert-danger" ng-show="invalidEmail">Unable to locate account.</div>
|
||||
<div class="alert alert-danger" ng-show="invalidRecovery">{{errorMessage}}</div>
|
||||
|
||||
<div class="alert alert-success" ng-show="sent">Account recovery email was sent.</div>
|
||||
</div>
|
||||
|
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 70 KiB |
BIN
static/img/org-logs.png
Normal file
After Width: | Height: | Size: 78 KiB |
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 64 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 53 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 61 KiB |
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 196 KiB |
719
static/js/app.js
13
static/js/bootstrap.js
vendored
Normal 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);
|
||||
}
|
||||
});
|
|
@ -1,10 +1,37 @@
|
|||
/**
|
||||
* Bind polyfill from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind#Compatibility
|
||||
*/
|
||||
if (!Function.prototype.bind) {
|
||||
Function.prototype.bind = function (oThis) {
|
||||
if (typeof this !== "function") {
|
||||
// closest thing possible to the ECMAScript 5 internal IsCallable function
|
||||
throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
|
||||
}
|
||||
|
||||
var aArgs = Array.prototype.slice.call(arguments, 1),
|
||||
fToBind = this,
|
||||
fNOP = function () {},
|
||||
fBound = function () {
|
||||
return fToBind.apply(this instanceof fNOP && oThis
|
||||
? this
|
||||
: oThis,
|
||||
aArgs.concat(Array.prototype.slice.call(arguments)));
|
||||
};
|
||||
|
||||
fNOP.prototype = this.prototype;
|
||||
fBound.prototype = new fNOP();
|
||||
|
||||
return fBound;
|
||||
};
|
||||
}
|
||||
|
||||
var DEPTH_HEIGHT = 100;
|
||||
var DEPTH_WIDTH = 132;
|
||||
|
||||
/**
|
||||
* Based off of http://mbostock.github.io/d3/talk/20111018/tree.html by Mike Bostock (@mbostock)
|
||||
*/
|
||||
function ImageHistoryTree(namespace, name, images, formatComment, formatTime) {
|
||||
function ImageHistoryTree(namespace, name, images, formatComment, formatTime, formatCommand) {
|
||||
/**
|
||||
* The namespace of the repo.
|
||||
*/
|
||||
|
@ -30,6 +57,11 @@ function ImageHistoryTree(namespace, name, images, formatComment, formatTime) {
|
|||
*/
|
||||
this.formatTime_ = formatTime;
|
||||
|
||||
/**
|
||||
* Method to invoke to format the command for an image.
|
||||
*/
|
||||
this.formatCommand_ = formatCommand;
|
||||
|
||||
/**
|
||||
* The current tag (if any).
|
||||
*/
|
||||
|
@ -40,6 +72,11 @@ function ImageHistoryTree(namespace, name, images, formatComment, formatTime) {
|
|||
*/
|
||||
this.currentImage_ = null;
|
||||
|
||||
/**
|
||||
* The currently highlighted node (if any).
|
||||
*/
|
||||
this.currentNode_ = null;
|
||||
|
||||
/**
|
||||
* Counter for creating unique IDs.
|
||||
*/
|
||||
|
@ -54,7 +91,7 @@ ImageHistoryTree.prototype.calculateDimensions_ = function(container) {
|
|||
var cw = Math.max(document.getElementById(container).clientWidth, this.maxWidth_ * DEPTH_WIDTH);
|
||||
var ch = this.maxHeight_ * (DEPTH_HEIGHT + 10);
|
||||
|
||||
var margin = { top: 40, right: 20, bottom: 20, left: 40 };
|
||||
var margin = { top: 40, right: 20, bottom: 20, left: 80 };
|
||||
var m = [margin.top, margin.right, margin.bottom, margin.left];
|
||||
var w = cw - m[1] - m[3];
|
||||
var h = ch - m[0] - m[2];
|
||||
|
@ -69,6 +106,25 @@ ImageHistoryTree.prototype.calculateDimensions_ = function(container) {
|
|||
};
|
||||
|
||||
|
||||
ImageHistoryTree.prototype.setupOverscroll_ = function() {
|
||||
var container = this.container_;
|
||||
var that = this;
|
||||
var overscroll = $('#' + container).overscroll();
|
||||
|
||||
overscroll.on('overscroll:dragstart', function() {
|
||||
$(that).trigger({
|
||||
'type': 'hideTagMenu'
|
||||
});
|
||||
});
|
||||
|
||||
overscroll.on('scroll', function() {
|
||||
$(that).trigger({
|
||||
'type': 'hideTagMenu'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Updates the dimensions of the tree.
|
||||
*/
|
||||
|
@ -86,17 +142,22 @@ ImageHistoryTree.prototype.updateDimensions_ = function() {
|
|||
$('#' + container).removeOverscroll();
|
||||
var viewportHeight = $(window).height();
|
||||
var boundingBox = document.getElementById(container).getBoundingClientRect();
|
||||
document.getElementById(container).style.maxHeight = (viewportHeight - boundingBox.top - 110) + 'px';
|
||||
$('#' + container).overscroll();
|
||||
document.getElementById(container).style.maxHeight = (viewportHeight - boundingBox.top - 150) + 'px';
|
||||
|
||||
this.setupOverscroll_();
|
||||
|
||||
// Update the tree.
|
||||
var rootSvg = this.rootSvg_;
|
||||
var tree = this.tree_;
|
||||
var vis = this.vis_;
|
||||
|
||||
|
||||
var ow = w + m[1] + m[3];
|
||||
var oh = h + m[0] + m[2];
|
||||
rootSvg
|
||||
.attr("width", w + m[1] + m[3])
|
||||
.attr("height", h + m[0] + m[2]);
|
||||
.attr("width", ow)
|
||||
.attr("height", oh)
|
||||
.attr("style", "width: " + ow + "px; height: " + oh + "px");
|
||||
|
||||
tree.size([w, h]);
|
||||
vis.attr("transform", "translate(" + m[3] + "," + m[0] + ")");
|
||||
|
@ -131,6 +192,8 @@ ImageHistoryTree.prototype.draw = function(container) {
|
|||
|
||||
var formatComment = this.formatComment_;
|
||||
var formatTime = this.formatTime_;
|
||||
var formatCommand = this.formatCommand_;
|
||||
|
||||
var tip = d3.tip()
|
||||
.attr('class', 'd3-tip')
|
||||
.offset([-1, 24])
|
||||
|
@ -156,8 +219,10 @@ ImageHistoryTree.prototype.draw = function(container) {
|
|||
if (d.image.comment) {
|
||||
html += '<span class="comment">' + formatComment(d.image.comment) + '</span>';
|
||||
}
|
||||
html += '<span class="created">' + formatTime(d.image.created) + '</span>';
|
||||
html += '<span class="full-id">' + d.image.id + '</span>';
|
||||
if (d.image.command && d.image.command.length) {
|
||||
html += '<span class="command info-line"><i class="fa fa-terminal"></i>' + formatCommand(d.image) + '</span>';
|
||||
}
|
||||
html += '<span class="created info-line"><i class="fa fa-calendar"></i>' + formatTime(d.image.created) + '</span>';
|
||||
return html;
|
||||
})
|
||||
|
||||
|
@ -178,8 +243,7 @@ ImageHistoryTree.prototype.draw = function(container) {
|
|||
this.root_.y0 = 0;
|
||||
|
||||
this.setTag_(this.currentTag_);
|
||||
|
||||
$('#' + container).overscroll();
|
||||
this.setupOverscroll_();
|
||||
};
|
||||
|
||||
|
||||
|
@ -208,6 +272,23 @@ ImageHistoryTree.prototype.setImage = function(imageId) {
|
|||
};
|
||||
|
||||
|
||||
/**
|
||||
* Updates the highlighted path in the tree.
|
||||
*/
|
||||
ImageHistoryTree.prototype.setHighlightedPath_ = function(image) {
|
||||
if (this.currentNode_) {
|
||||
this.markPath_(this.currentNode_, false);
|
||||
}
|
||||
|
||||
var imageByDBID = this.imageByDBID_;
|
||||
var currentNode = imageByDBID[image.dbid];
|
||||
if (currentNode) {
|
||||
this.markPath_(currentNode, true);
|
||||
this.currentNode_ = currentNode;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Returns the ancestors of the given image.
|
||||
*/
|
||||
|
@ -445,26 +526,15 @@ ImageHistoryTree.prototype.setTag_ = function(tagName) {
|
|||
// Save the current tag.
|
||||
var previousTagName = this.currentTag_;
|
||||
this.currentTag_ = tagName;
|
||||
this.currentImage_ = null;
|
||||
|
||||
// Update the state of each existing node to no longer be highlighted.
|
||||
var previousImage = this.findImage_(function(image) {
|
||||
return image.tags.indexOf(previousTagName || '(no tag specified)') >= 0;
|
||||
});
|
||||
|
||||
if (previousImage) {
|
||||
var currentNode = imageByDBID[previousImage.dbid];
|
||||
this.markPath_(currentNode, false);
|
||||
}
|
||||
|
||||
// Find the new current image (if any).
|
||||
this.currentImage_ = this.findImage_(function(image) {
|
||||
// Update the path.
|
||||
var tagImage = this.findImage_(function(image) {
|
||||
return image.tags.indexOf(tagName || '(no tag specified)') >= 0;
|
||||
});
|
||||
|
||||
// Update the state of the new node path.
|
||||
if (this.currentImage_) {
|
||||
var currentNode = imageByDBID[this.currentImage_.dbid];
|
||||
this.markPath_(currentNode, true);
|
||||
if (tagImage) {
|
||||
this.setHighlightedPath_(tagImage);
|
||||
}
|
||||
|
||||
// Ensure that the children are in the correct order.
|
||||
|
@ -508,7 +578,9 @@ ImageHistoryTree.prototype.setImage_ = function(imageId) {
|
|||
return;
|
||||
}
|
||||
|
||||
this.setHighlightedPath_(newImage);
|
||||
this.currentImage_ = newImage;
|
||||
this.currentTag_ = null;
|
||||
this.update_(this.root_);
|
||||
};
|
||||
|
||||
|
@ -637,7 +709,7 @@ ImageHistoryTree.prototype.update_ = function(source) {
|
|||
if (tag == currentTag) {
|
||||
kind = 'success';
|
||||
}
|
||||
html += '<span class="label label-' + kind + ' tag" data-tag="' + tag + '">' + tag + '</span>';
|
||||
html += '<span class="label label-' + kind + ' tag" data-tag="' + tag + '"">' + tag + '</span>';
|
||||
}
|
||||
return html;
|
||||
});
|
||||
|
@ -649,6 +721,19 @@ ImageHistoryTree.prototype.update_ = function(source) {
|
|||
if (tag) {
|
||||
that.changeTag_(tag);
|
||||
}
|
||||
})
|
||||
.on("contextmenu", function(d, e) {
|
||||
d3.event.preventDefault();
|
||||
|
||||
var tag = this.getAttribute('data-tag');
|
||||
if (tag) {
|
||||
$(that).trigger({
|
||||
'type': 'showTagMenu',
|
||||
'tag': tag,
|
||||
'clientX': d3.event.clientX,
|
||||
'clientY': d3.event.clientY
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the tags are visible.
|
||||
|
|
32
static/lib/angulartics-google-analytics.js
vendored
Normal 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);
|
793
static/lib/jquery.overscroll.js
Normal 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
|
@ -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
|
@ -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
|
||||
})(); //
|
|
@ -93,6 +93,15 @@ Email: my@email.com</pre>
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<h3>Deleting a tag <span class="label label-info">Requires Admin Access</span></h3>
|
||||
<div class="container">
|
||||
<div class="description-overview">
|
||||
A specific tag and all its images can be deleted by right clicking on the tag in the repository history tree and choosing "Delete Tag". This will delete the tag and any images <b>unique to it</b>. Images will not be deleted until all tags sharing them are deleted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a name="#post-hook"></a>
|
||||
<h3>Using push webhooks <span class="label label-info">Requires Admin Access</span></h3>
|
||||
<div class="container">
|
||||
|
@ -101,16 +110,16 @@ Email: my@email.com</pre>
|
|||
as an HTTP <b>POST</b> to the specified URL, with a JSON body describing the push:<br><br>
|
||||
<pre>
|
||||
{
|
||||
<span class="code-info" title="The number of images pushed" bs-tooltip="tooltip.title">"pushed_image_count"</span>: 2,
|
||||
<span class="code-info" title="The name of the repository (without its namespace)" bs-tooltip="tooltip.title">"name"</span>: "ubuntu",
|
||||
<span class="code-info" title="The full name of the repository" bs-tooltip="tooltip.title">"repository"</span>:"devtable/ubuntu",
|
||||
<span class="code-info" title="The URL at which the repository can be pulled by Docker" bs-tooltip="tooltip.title">"docker_url"</span>: "quay.io/devtable/ubuntu",
|
||||
<span class="code-info" title="Map of updated tag names to their latest image IDs" bs-tooltip="tooltip.title">"updated_tags"</span>: {
|
||||
<span class="context-tooltip" title="The number of images pushed" bs-tooltip="tooltip.title">"pushed_image_count"</span>: 2,
|
||||
<span class="context-tooltip" title="The name of the repository (without its namespace)" bs-tooltip="tooltip.title">"name"</span>: "ubuntu",
|
||||
<span class="context-tooltip" title="The full name of the repository" bs-tooltip="tooltip.title">"repository"</span>:"devtable/ubuntu",
|
||||
<span class="context-tooltip" title="The URL at which the repository can be pulled by Docker" bs-tooltip="tooltip.title">"docker_url"</span>: "quay.io/devtable/ubuntu",
|
||||
<span class="context-tooltip" title="Map of updated tag names to their latest image IDs" bs-tooltip="tooltip.title">"updated_tags"</span>: {
|
||||
"latest": "b750fe79269d2ec9a3c593ef05b4332b1d1a02a62b4accb2c21d589ff2f5f2dc"
|
||||
},
|
||||
<span class="code-info" title="The namespace of the repository" bs-tooltip="tooltip.title">"namespace"</span>: "devtable",
|
||||
<span class="code-info" title="Whether the repository is public or private" bs-tooltip="tooltip.title">"visibility"</span>: "private",
|
||||
<span class="code-info" title="The Quay URL for the repository" bs-tooltip="tooltip.title">"homepage"</span>: "https://quay.io/repository/devtable/ubuntu"
|
||||
<span class="context-tooltip" title="The namespace of the repository" bs-tooltip="tooltip.title">"namespace"</span>: "devtable",
|
||||
<span class="context-tooltip" title="Whether the repository is public or private" bs-tooltip="tooltip.title">"visibility"</span>: "private",
|
||||
<span class="context-tooltip" title="The Quay URL for the repository" bs-tooltip="tooltip.title">"homepage"</span>: "https://quay.io/repository/devtable/ubuntu"
|
||||
}
|
||||
</pre>
|
||||
</div>
|
||||
|
|
|
@ -1,97 +1,99 @@
|
|||
<div class="container" ng-show="!loading && !image">
|
||||
No image found
|
||||
</div>
|
||||
<div class="resource-view" resource="image" error-message="'No image found'">
|
||||
<div class="container repo repo-image-view">
|
||||
<div class="header">
|
||||
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="fa fa-chevron-left"></i></a>
|
||||
<h3>
|
||||
<i class="fa fa-archive fa-lg" style="color: #aaa; margin-right: 10px;"></i>
|
||||
<span style="color: #aaa;"> {{repo.namespace}}</span>
|
||||
<span style="color: #ccc">/</span>
|
||||
<span style="color: #666;">{{repo.name}}</span>
|
||||
<span style="color: #ccc">/</span>
|
||||
<span>{{image.value.id.substr(0, 12)}}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
<!-- Comment -->
|
||||
<blockquote ng-show="image.value.comment">
|
||||
<span class="markdown-view" content="image.value.comment"></span>
|
||||
</blockquote>
|
||||
|
||||
<div class="container repo repo-image-view" ng-show="!loading && image">
|
||||
<div class="header">
|
||||
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="fa fa-chevron-left"></i></a>
|
||||
<h3>
|
||||
<i class="fa fa-archive fa-lg" style="color: #aaa; margin-right: 10px;"></i>
|
||||
<span style="color: #aaa;"> {{repo.namespace}}</span>
|
||||
<span style="color: #ccc">/</span>
|
||||
<span style="color: #666;">{{repo.name}}</span>
|
||||
<span style="color: #ccc">/</span>
|
||||
<span>{{image.id.substr(0, 12)}}</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Comment -->
|
||||
<blockquote ng-show="image.comment">
|
||||
<span class="markdown-view" content="image.comment"></span>
|
||||
</blockquote>
|
||||
|
||||
<!-- Information -->
|
||||
<dl class="dl-normal">
|
||||
<dt>Full Image ID</dt>
|
||||
<dd>
|
||||
<div>
|
||||
<div class="id-container">
|
||||
<div class="input-group">
|
||||
<input id="full-id" type="text" class="form-control" value="{{ image.id }}" readonly>
|
||||
<span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="full-id">
|
||||
<i class="fa fa-copy"></i>
|
||||
</span>
|
||||
<!-- Information -->
|
||||
<dl class="dl-normal">
|
||||
<dt>Full Image ID</dt>
|
||||
<dd>
|
||||
<div>
|
||||
<div class="id-container">
|
||||
<div class="input-group">
|
||||
<input id="full-id" type="text" class="form-control" value="{{ image.value.id }}" readonly>
|
||||
<span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="full-id">
|
||||
<i class="fa fa-copy"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="clipboardCopied" style="display: none">
|
||||
Copied to clipboard
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="clipboardCopied" style="display: none">
|
||||
Copied to clipboard
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
<dt>Created</dt>
|
||||
<dd am-time-ago="parseDate(image.created)"></dd>
|
||||
</dl>
|
||||
</dd>
|
||||
<dt>Created</dt>
|
||||
<dd am-time-ago="parseDate(image.value.created)"></dd>
|
||||
<dt>Compressed Image Size</dt>
|
||||
<dd><span class="context-tooltip"
|
||||
title="The amount of data sent between Docker and Quay.io when pushing/pulling"
|
||||
bs-tooltip="tooltip.title" data-container="body">{{ image.value.size | bytes }}</span>
|
||||
</dd>
|
||||
|
||||
<!-- Changes tabs -->
|
||||
<div ng-show="combinedChanges.length > 0">
|
||||
<b>File Changes:</b>
|
||||
<br>
|
||||
<br>
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#filterable">Filterable View</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#tree" ng-click="initializeTree()">Tree View</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<dt ng-show="image.value.command && image.value.command.length">Command</dt>
|
||||
<dd ng-show="image.value.command && image.value.command.length">
|
||||
<pre class="formatted-command">{{ getFormattedCommand(image.value) }}</pre>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<!-- Changes tab content -->
|
||||
<div class="tab-content" ng-show="combinedChanges.length > 0">
|
||||
<!-- Filterable view -->
|
||||
<div class="tab-pane active" id="filterable">
|
||||
<div class="changes-container full-changes-container">
|
||||
<div class="change-side-controls">
|
||||
<div class="result-count">
|
||||
Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results
|
||||
</div>
|
||||
<div class="filter-input">
|
||||
<input id="change-filter" class="form-control" placeholder="Filter Changes" type="text" ng-model="search.$">
|
||||
</div>
|
||||
</div>
|
||||
<div style="height: 28px;"></div>
|
||||
<div class="changes-list well well-sm">
|
||||
<div ng-show="(combinedChanges | filter:search | limitTo:1).length == 0">
|
||||
No matching changes
|
||||
</div>
|
||||
<div class="change" ng-repeat="change in combinedChanges | filter:search | limitTo:50">
|
||||
<i ng-class="{'added': 'fa fa-plus-square', 'removed': 'fa fa-minus-square', 'changed': 'fa fa-pencil-square'}[change.kind]"></i>
|
||||
<span title="{{change.file}}">
|
||||
<span style="color: #888;">
|
||||
<span ng-repeat="folder in getFolders(change.file)"><a href="javascript:void(0)" ng-click="setFolderFilter(getFolder(change.file), $index)">{{folder}}</a>/</span></span><span>{{getFilename(change.file)}}</span>
|
||||
</span>
|
||||
<!-- Changes tabs -->
|
||||
<div ng-show="combinedChanges.length > 0">
|
||||
<b>File Changes:</b>
|
||||
<br>
|
||||
<br>
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#filterable">Filterable View</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#tree" ng-click="initializeTree()">Tree View</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Changes tab content -->
|
||||
<div class="tab-content" ng-show="combinedChanges.length > 0">
|
||||
<!-- Filterable view -->
|
||||
<div class="tab-pane active" id="filterable">
|
||||
<div class="changes-container full-changes-container">
|
||||
<div class="change-side-controls">
|
||||
<div class="result-count">
|
||||
Showing {{(combinedChanges | filter:search | limitTo:50).length}} of {{(combinedChanges | filter:search).length}} results
|
||||
</div>
|
||||
<div class="filter-input">
|
||||
<input id="change-filter" class="form-control" placeholder="Filter Changes" type="text" ng-model="search.$">
|
||||
</div>
|
||||
</div>
|
||||
<div style="height: 28px;"></div>
|
||||
<div class="changes-list well well-sm">
|
||||
<div ng-show="(combinedChanges | filter:search | limitTo:1).length == 0">
|
||||
No matching changes
|
||||
</div>
|
||||
<div class="change" ng-repeat="change in combinedChanges | filter:search | limitTo:50">
|
||||
<i ng-class="{'added': 'fa fa-plus-square', 'removed': 'fa fa-minus-square', 'changed': 'fa fa-pencil-square'}[change.kind]"></i>
|
||||
<span title="{{change.file}}">
|
||||
<span style="color: #888;">
|
||||
<span ng-repeat="folder in getFolders(change.file)"><a href="javascript:void(0)" ng-click="setFolderFilter(getFolder(change.file), $index)">{{folder}}</a>/</span></span><span>{{getFilename(change.file)}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tree view -->
|
||||
<div class="tab-pane" id="tree">
|
||||
<div id="changes-tree-container" class="changes-container" onresize="tree && tree.notifyResized()"></div>
|
||||
</div>
|
||||
<!-- Tree view -->
|
||||
<div class="tab-pane" id="tree">
|
||||
<div id="changes-tree-container" class="changes-container" onresize="tree && tree.notifyResized()"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
|
|
@ -5,29 +5,32 @@
|
|||
<div ng-show="user.anonymous">
|
||||
<h1>Secure hosting for <b>private</b> Docker<a class="disclaimer-link" href="/disclaimer" target="_self">*</a> repositories</h1>
|
||||
<h3>Use the Docker images <b>your team</b> needs with the safety of <b>private</b> repositories</h3>
|
||||
<div class="sellcall"><a href="/plans/">Private repository plans starting at $7/mo</a></div>
|
||||
<div class="sellcall"><a href="/plans/">Private repository plans starting at $12/mo</a></div>
|
||||
</div>
|
||||
|
||||
<div ng-show="!user.anonymous">
|
||||
<div ng-show="loadingmyrepos">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
<span class="namespace-selector" user="user" namespace="namespace" ng-show="!loadingmyrepos && user.organizations"></span>
|
||||
<div ng-show="!loadingmyrepos && myrepos.length > 0">
|
||||
<h2>Top Repositories</h2>
|
||||
<div class="repo-listing" ng-repeat="repository in myrepos">
|
||||
<span class="repo-circle no-background" repo="repository"></span>
|
||||
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
|
||||
<div class="markdown-view description" content="repository.description" first-line-only="true"></div>
|
||||
<span class="namespace-selector" user="user" namespace="namespace" ng-show="user.organizations"></span>
|
||||
|
||||
<div class="resource-view" resource="my_repositories">
|
||||
<!-- Repos -->
|
||||
<div ng-show="my_repositories.value.length > 0">
|
||||
<h2>Top Repositories</h2>
|
||||
<div class="repo-listing" ng-repeat="repository in my_repositories.value">
|
||||
<span class="repo-circle no-background" repo="repository"></span>
|
||||
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
|
||||
<div class="markdown-view description" content="repository.description" first-line-only="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="!loadingmyrepos && myrepos.length == 0">
|
||||
<div class="sub-message" style="margin-top: 20px">
|
||||
<span ng-show="namespace != user.username">You don't have access to any repositories in this organization yet.</span>
|
||||
<span ng-show="namespace == user.username">You don't have any repositories yet!</span>
|
||||
<div class="options">
|
||||
<a class="btn btn-primary" href="/repository/">Browse all repositories</a>
|
||||
<a class="btn btn-success" href="/new/" ng-show="canCreateRepo(namespace)">Create a new repository</a>
|
||||
|
||||
<!-- No Repos -->
|
||||
<div ng-show="my_repositories.value.length == 0">
|
||||
<div class="sub-message" style="margin-top: 20px">
|
||||
<span ng-show="namespace != user.username">You don't have access to any repositories in this organization yet.</span>
|
||||
<span ng-show="namespace == user.username">You don't have any repositories yet!</span>
|
||||
<div class="options">
|
||||
<a class="btn btn-primary" href="/repository/">Browse all repositories</a>
|
||||
<a class="btn btn-success" href="/new/" ng-show="canCreateRepo(namespace)">Create a new repository</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -41,8 +44,8 @@
|
|||
<div ng-show="!user.anonymous" class="user-welcome">
|
||||
<img class="gravatar" src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=128&d=identicon" />
|
||||
<div class="sub-message">Welcome <b>{{ user.username }}</b>!</div>
|
||||
<a ng-show="myrepos" class="btn btn-primary" href="/repository/">Browse all repositories</a>
|
||||
<a ng-show="myrepos" class="btn btn-success" href="/new/">Create a new repository</a>
|
||||
<a ng-show="my_repositories.value" class="btn btn-primary" href="/repository/">Browse all repositories</a>
|
||||
<a class="btn btn-success" href="/new/">Create a new repository</a>
|
||||
</div>
|
||||
</div> <!-- col -->
|
||||
</div> <!-- row -->
|
||||
|
@ -82,7 +85,7 @@
|
|||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7"><img src="/static/img/user-home.png" title="User Home - Quay" data-screenshot-url="https://quay.io/" class="img-responsive"></div>
|
||||
<div class="col-md-7"><img src="/static/img/user-home.png" title="User Home - Quay.io" data-screenshot-url="https://quay.io/" class="img-responsive"></div>
|
||||
<div class="col-md-5">
|
||||
<div class="tour-section-title">Customized for you</div>
|
||||
<div class="tour-section-description">
|
||||
|
@ -93,7 +96,7 @@
|
|||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7 col-md-push-5"><img src="/static/img/repo-view.png" title="Repository View - Quay" data-screenshot-url="https://quay.io/repository/devtable/complex" class="img-responsive"></div>
|
||||
<div class="col-md-7 col-md-push-5"><img src="/static/img/repo-view.png" title="Repository View - Quay.io" data-screenshot-url="https://quay.io/repository/devtable/complex" class="img-responsive"></div>
|
||||
<div class="col-md-5 col-md-pull-7">
|
||||
<div class="tour-section-title">Useful views of respositories</div>
|
||||
<div class="tour-section-description">
|
||||
|
@ -103,7 +106,7 @@
|
|||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7"><img src="/static/img/repo-changes.png" title="View Image - Quay" data-screenshot-url="https://quay.io/repository/devtable/image/..." class="img-responsive"></div>
|
||||
<div class="col-md-7"><img src="/static/img/repo-changes.png" title="View Image - Quay.io" data-screenshot-url="https://quay.io/repository/devtable/image/..." class="img-responsive"></div>
|
||||
<div class="col-md-5">
|
||||
<div class="tour-section-title">Docker diff in the cloud</div>
|
||||
<div class="tour-section-description">
|
||||
|
@ -113,7 +116,7 @@
|
|||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7 col-md-push-5"><img src="/static/img/repo-admin.png" title="Repository Admin - Quay" data-screenshot-url="https://quay.io/repository/devtable/complex/admin" class="img-responsive"></div>
|
||||
<div class="col-md-7 col-md-push-5"><img src="/static/img/repo-admin.png" title="Repository Admin - Quay.io" data-screenshot-url="https://quay.io/repository/devtable/complex/admin" class="img-responsive"></div>
|
||||
<div class="col-md-5 col-md-pull-7">
|
||||
<div class="tour-section-title">Share at your control</div>
|
||||
<div class="tour-section-description">
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<div class="loading" ng-show="loading || creating">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
<div class="loading" ng-show="creating">
|
||||
<div class="quay-spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="container create-org" ng-show="!loading && !creating">
|
||||
<div class="container create-org" ng-show="!creating">
|
||||
|
||||
<div class="row header-row">
|
||||
<div class="col-md-8 col-md-offset-1">
|
||||
|
@ -72,7 +72,8 @@
|
|||
</div>
|
||||
|
||||
<div class="button-bar">
|
||||
<button class="btn btn-large btn-success" type="submit" ng-disabled="newOrgForm.$invalid || !currentPlan">
|
||||
<button class="btn btn-large btn-success" type="submit" ng-disabled="newOrgForm.$invalid || !currentPlan"
|
||||
analytics-on analytics-event="create_organization">
|
||||
Create Organization
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
<div class="container" ng-show="user.anonymous">
|
||||
<h3>Please <a href="/signin/">sign in</a></h3>
|
||||
<div class="col-sm-6 col-sm-offset-3">
|
||||
<div class="user-setup" redirect-url="'/new/'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" ng-show="!user.anonymous && building">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
<div class="quay-spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="container" ng-show="!user.anonymous && creating">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
<div class="quay-spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="container" ng-show="!user.anonymous && uploading">
|
||||
|
@ -72,12 +74,18 @@
|
|||
<!-- Payment -->
|
||||
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && isUserNamespace">
|
||||
<div class="alert alert-warning">
|
||||
In order to make this repository private, you’ll 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, you’ll need to upgrade your plan to
|
||||
<b style="border-bottom: 1px dotted black;" bs-tooltip="'<b>' + planRequired.title + '</b><br>' + planRequired.privateRepos + ' private repositories'">
|
||||
{{ planRequired.title }}
|
||||
</b>.
|
||||
This will cost $<span>{{ planRequired.price / 100 }}</span>/month.
|
||||
</div>
|
||||
<a class="btn btn-primary" ng-click="upgradePlan()" ng-show="!planChanging">Upgrade now</a>
|
||||
<i class="fa fa-spinner fa-spin fa-3x" ng-show="planChanging"></i>
|
||||
<div class="quay-spinner" ng-show="planChanging"></div>
|
||||
</div>
|
||||
|
||||
<div class="quay-spinner" ng-show="repo.is_public == '0' && checkingPlan"></div>
|
||||
|
||||
<div class="required-plan" ng-show="repo.is_public == '0' && planRequired && !isUserNamespace">
|
||||
<div class="alert alert-warning">
|
||||
This organization has reached its private repository limit. Please contact your administrator.
|
||||
|
@ -112,7 +120,10 @@
|
|||
<div class="row">
|
||||
<div class="col-md-1"></div>
|
||||
<div class="col-md-8">
|
||||
<button class="btn btn-large btn-success" type="submit" ng-disabled="newRepoForm.$invalid || (repo.is_public == '0' && planRequired)">Create Repository</button>
|
||||
<button class="btn btn-large btn-success" type="submit"
|
||||
ng-disabled="newRepoForm.$invalid || (repo.is_public == '0' && (planRequired || checkingPlan))">
|
||||
Create Repository
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,12 +1,5 @@
|
|||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="loading" ng-show="!loading && !organization">
|
||||
No matching organization found
|
||||
</div>
|
||||
|
||||
<div class="org-admin container" ng-show="!loading && organization">
|
||||
<div class="resource-view" resource="orgResource" error-message="'No organization found'"></div>
|
||||
<div class="org-admin container" ng-show="organization">
|
||||
<div class="organization-header" organization="organization" clickable="true"></div>
|
||||
|
||||
<div class="row">
|
||||
|
@ -47,66 +40,12 @@
|
|||
|
||||
<!-- Billing History tab -->
|
||||
<div id="billing" class="tab-pane">
|
||||
<div ng-show="invoiceLoading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div ng-show="!invoiceLoading && !invoices">
|
||||
No invoices have been created
|
||||
</div>
|
||||
|
||||
<div ng-show="!invoiceLoading && invoices">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<th>Billing Date/Time</th>
|
||||
<th>Amount Due</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</thead>
|
||||
|
||||
<tbody class="invoice" ng-repeat="invoice in invoices">
|
||||
<tr class="invoice-title">
|
||||
<td ng-click="toggleInvoice(invoice.id)"><span class="invoice-datetime">{{ invoice.date * 1000 | date:'medium' }}</span></td>
|
||||
<td ng-click="toggleInvoice(invoice.id)"><span class="invoice-amount">{{ invoice.amount_due / 100 }}</span></td>
|
||||
<td>
|
||||
<span class="invoice-status">
|
||||
<span class="success" ng-show="invoice.paid">Paid - Thank you!</span>
|
||||
<span class="danger" ng-show="!invoice.paid && invoice.attempted && invoice.closed">Payment failed</span>
|
||||
<span class="danger" ng-show="!invoice.paid && invoice.attempted && !invoice.closed">Payment failed - Will retry soon</span>
|
||||
<span class="pending" ng-show="!invoice.paid && !invoice.attempted">Payment pending</span>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a ng-show="invoice.paid" href="/receipt?id={{ invoice.id }}" download="receipt.pdf" target="_new">
|
||||
<i class="fa fa-download" title="Download Receipt" bs-tooltip="tooltip.title"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr ng-class="invoiceExpanded[invoice.id] ? 'in' : 'out'" class="invoice-details panel-collapse collapse">
|
||||
<td colspan="3">
|
||||
<dl class="dl-normal">
|
||||
<dt>Billing Period</dt>
|
||||
<dd>
|
||||
<span>{{ invoice.period_start * 1000 | date:'mediumDate' }}</span> -
|
||||
<span>{{ invoice.period_end * 1000 | date:'mediumDate' }}</span>
|
||||
</dd>
|
||||
<dt>Plan</dt>
|
||||
<dd>
|
||||
<span>{{ invoice.plan ? plan_map[invoice.plan].title : '(N/A)' }}</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="billing-invoices" organization="organization" visible="invoicesShown"></div>
|
||||
</div>
|
||||
|
||||
<!-- Members tab -->
|
||||
<div id="members" class="tab-pane">
|
||||
<i class="fa fa-spinner fa-spin fa-3x" ng-show="membersLoading"></i>
|
||||
|
||||
<div class="quay-spinner" ng-show="membersLoading"></div>
|
||||
<div ng-show="!membersLoading">
|
||||
<div class="side-controls">
|
||||
<div class="result-count">
|
||||
|
|
|
@ -1,16 +1,6 @@
|
|||
<div class="org-member-logs container" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="container" ng-show="!loading && !organization">
|
||||
Organization not found
|
||||
</div>
|
||||
|
||||
<div class="container" ng-show="!loading && !memberInfo">
|
||||
Member not found
|
||||
</div>
|
||||
|
||||
<div class="org-member-logs container" ng-show="!loading && organization && memberInfo">
|
||||
<div class="organization-header" organization="organization" clickable="true"></div>
|
||||
<div class="logs-view" organization="organization" performer="memberInfo" visible="organization && memberInfo && ready"></div>
|
||||
<div class="resource-view" resource="memberResource" error-message="'Member not found'">
|
||||
<div class="org-member-logs container">
|
||||
<div class="organization-header" organization="organization" clickable="true"></div>
|
||||
<div class="logs-view" organization="organization" performer="memberInfo" visible="organization && memberInfo && ready"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,51 +1,45 @@
|
|||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
<div class="resource-view" resource="orgResource" error-message="'No matching organization found'">
|
||||
<div class="org-view container">
|
||||
<div class="organization-header" organization="organization">
|
||||
<div class="header-buttons" ng-show="organization.is_admin">
|
||||
|
||||
<div class="loading" ng-show="!loading && !organization">
|
||||
No matching organization found
|
||||
</div>
|
||||
<span class="popup-input-button" pattern="TEAM_PATTERN" placeholder="'Team Name'"
|
||||
submitted="createTeam(value)">
|
||||
<i class="fa fa-group"></i> Create Team
|
||||
</span>
|
||||
|
||||
<div class="org-view container" ng-show="!loading && organization">
|
||||
<div class="organization-header" organization="organization">
|
||||
<div class="header-buttons" ng-show="organization.is_admin">
|
||||
|
||||
<span class="popup-input-button" pattern="TEAM_PATTERN" placeholder="'Team Name'"
|
||||
submitted="createTeam(value)">
|
||||
<i class="fa fa-group"></i> Create Team
|
||||
</span>
|
||||
|
||||
<a class="btn btn-default" href="/organization/{{ organization.name }}/admin"><i class="fa fa-gear"></i> Settings</a>
|
||||
<a class="btn btn-default" href="/organization/{{ organization.name }}/admin"><i class="fa fa-gear"></i> Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row hidden-xs">
|
||||
<div class="col-md-4 col-md-offset-8 col-sm-5 col-sm-offset-7 header-col" ng-show="organization.is_admin">
|
||||
Team Permissions
|
||||
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" title=""
|
||||
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"></i>
|
||||
<div class="row hidden-xs">
|
||||
<div class="col-md-4 col-md-offset-8 col-sm-5 col-sm-offset-7 header-col" ng-show="organization.is_admin">
|
||||
Team Permissions
|
||||
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" title=""
|
||||
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="team-listing" ng-repeat="(name, team) in organization.teams">
|
||||
<div id="team-{{name}}" class="row">
|
||||
<div class="col-sm-7 col-md-8">
|
||||
<div class="team-title">
|
||||
<i class="fa fa-group"></i>
|
||||
<span ng-show="team.can_view">
|
||||
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a>
|
||||
</span>
|
||||
<span ng-show="!team.can_view">
|
||||
{{ team.name }}
|
||||
</span>
|
||||
<div class="team-listing" ng-repeat="(name, team) in organization.teams">
|
||||
<div id="team-{{name}}" class="row">
|
||||
<div class="col-sm-7 col-md-8">
|
||||
<div class="team-title">
|
||||
<i class="fa fa-group"></i>
|
||||
<span ng-show="team.can_view">
|
||||
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a>
|
||||
</span>
|
||||
<span ng-show="!team.can_view">
|
||||
{{ team.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div>
|
||||
</div>
|
||||
|
||||
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin">
|
||||
<span class="role-group" current-role="team.role" role-changed="setRole(role, team.name)" roles="teamRoles"></span>
|
||||
<button class="btn btn-sm btn-danger" ng-click="askDeleteTeam(team.name)">Delete</button>
|
||||
<div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin">
|
||||
<span class="role-group" current-role="team.role" role-changed="setRole(role, team.name)" roles="teamRoles"></span>
|
||||
<button class="btn btn-sm btn-danger" ng-click="askDeleteTeam(team.name)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<div class="container org-list conntent-container">
|
||||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
<div class="loading" ng-show="!user">
|
||||
<div class="quay-spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="button-bar-right">
|
||||
|
@ -43,7 +43,7 @@
|
|||
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7"><img src="/static/img/org-repo-list.png" title="Repositories - Quay" data-screenshot-url="https://quay.io/repository/" class="img-responsive"></div>
|
||||
<div class="col-md-7"><img src="/static/img/org-repo-list.png" title="Repositories - Quay.io" data-screenshot-url="https://quay.io/repository/" class="img-responsive"></div>
|
||||
<div class="col-md-5">
|
||||
<div class="tour-section-title">A central collection of repositories</div>
|
||||
<div class="tour-section-description">
|
||||
|
@ -57,7 +57,7 @@
|
|||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-admin.png" title="buynlarge Admin - Quay" data-screenshot-url="https://quay.io/organization/buynlarge/admin" class="img-responsive"></div>
|
||||
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-admin.png" title="buynlarge Admin - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge/admin" class="img-responsive"></div>
|
||||
<div class="col-md-5 col-md-pull-7">
|
||||
<div class="tour-section-title">Organization settings at a glance</div>
|
||||
<div class="tour-section-description">
|
||||
|
@ -73,8 +73,29 @@
|
|||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7"><img src="/static/img/org-teams.png" title="buynlarge - Quay" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
|
||||
<div class="col-md-7"><img src="/static/img/org-logs.png" title="buynlarge Admin - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
|
||||
<div class="col-md-5">
|
||||
<div class="tour-section-title">Logging for comprehensive analysis</div>
|
||||
<div class="tour-section-description">
|
||||
Every time a user in your organization performs an action it is logged
|
||||
and categorized in a way that allows for a complete understanding of
|
||||
how your repositories have been accessed and modified. Each log entry
|
||||
includes the action performed, the authorization which allowed the action
|
||||
to occur, and additional relevant data such as the name of the item
|
||||
which was modified or accessed.
|
||||
</div>
|
||||
<div class="tour-section-description">
|
||||
For those times when you need full control when generating reports from
|
||||
your logs, we also allow you to export your logs in JSON format. These
|
||||
can be ingested by custom tooling solutions allowing you to visualize
|
||||
reports in whatever format you require.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-teams.png" title="buynlarge - Quay.io" data-screenshot-url="https://quay.io/organization/buynlarge" class="img-responsive"></div>
|
||||
<div class="col-md-5 col-md-pull-7">
|
||||
<div class="tour-section-title">Teams simplify access controls</div>
|
||||
<div class="tour-section-description">
|
||||
Teams allow your organization to delegate access to your namespace and
|
||||
|
@ -94,8 +115,8 @@
|
|||
</div>
|
||||
|
||||
<div class="tour-section row">
|
||||
<div class="col-md-7 col-md-push-5"><img src="/static/img/org-repo-admin.png" title="buynlarge/orgrepo - Quay" data-screenshot-url="https://quay.io/repository/buynlarge/orgrepo" class="img-responsive"></div>
|
||||
<div class="col-md-5 col-md-pull-7">
|
||||
<div class="col-md-7"><img src="/static/img/org-repo-admin.png" title="buynlarge/orgrepo - Quay.io" data-screenshot-url="https://quay.io/repository/buynlarge/orgrepo" class="img-responsive"></div>
|
||||
<div class="col-md-5">
|
||||
<div class="tour-section-title">Fine-grained control of sharing</div>
|
||||
<div class="tour-section-description">
|
||||
Repositories that you create within your organization can be assigned
|
||||
|
|
|
@ -1,44 +1,107 @@
|
|||
<div class="container plans content-container">
|
||||
<div class="callout">
|
||||
Plans & Pricing
|
||||
</div>
|
||||
|
||||
<div class="all-plans">
|
||||
All plans include <span class="feature">unlimited public repositories</span> and <span class="feature">unlimited sharing</span>. All paid plans have a <span class="feature">14-day free trial</span>.
|
||||
</div>
|
||||
|
||||
<div class="row plans-list">
|
||||
<div class="col-xs-0 col-lg-1"></div>
|
||||
<div class="col-lg-2 col-xs-4 plan-container" ng-repeat="plan in plans.user">
|
||||
<div class="plan" ng-class="plan.stripeId">
|
||||
<div class="plan-title">{{ plan.title }}</div>
|
||||
<div class="plan-price">${{ plan.price/100 }}</div>
|
||||
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
|
||||
<div class="description">{{ plan.audience }}</div>
|
||||
<div class="smaller">SSL secured connections</div>
|
||||
<button class="btn btn-primary btn-block" ng-click="buyNow(plan.stripeId)">Sign Up Now</button>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<div class="features-bar hidden-xs">
|
||||
<div class="visible-lg" style="height: 50px"></div>
|
||||
<div class="visible-md visible-sm" style="height: 70px"></div>
|
||||
|
||||
<div class="feature">
|
||||
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||
title="All plans have unlimited public repositories">
|
||||
<span class="hidden-sm-inline">Public Repositories</span>
|
||||
<span class="visible-sm-inline">Public Repos</span>
|
||||
</span>
|
||||
<i class="fa fa-hdd visible-lg"></i>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||
title="SSL encryption is enabled end-to-end for all operations">
|
||||
SSL Encryption
|
||||
</span>
|
||||
<i class="fa fa-lock visible-lg"></i>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||
title="Allows users or organizations to grant permissions in multiple repositories to the same non-login-capable account">
|
||||
Robot accounts
|
||||
</span>
|
||||
<i class="fa fa-wrench visible-lg"></i>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||
title="Repository images can be built directly from Dockerfiles">
|
||||
Dockerfile Build
|
||||
</span>
|
||||
<i class="fa fa-upload visible-lg"></i>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||
title="Grant subsets of users in an organization their own permissions, either on a global basis or a per-repository basis">
|
||||
Teams
|
||||
</span>
|
||||
<i class="fa fa-group visible-lg"></i>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||
title="Every action take within an organization is logged in detail, with the ability to visualize logs and download them">
|
||||
Logging
|
||||
</span>
|
||||
<i class="fa fa-bar-chart-o visible-lg"></i>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||
title="Administrators can view and download the full invoice history for their organization">
|
||||
Invoice History
|
||||
</span>
|
||||
<i class="fa fa-calendar visible-lg"></i>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="context-tooltip" bs-tooltip="tooltip.title" data-container="body" data-placement="right"
|
||||
title="All plans have a 14-day free trial">
|
||||
<span class="hidden-sm-inline">14-Day Free Trial</span>
|
||||
<span class="visible-sm-inline">14-Day Trial</span>
|
||||
</span>
|
||||
<i class="fa fa-clock-o visible-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-2 plan-container" ng-repeat="plan in plans" ng-show="plan.price > 0 && !plan.deprecated">
|
||||
<div class="plan" ng-class="plan.stripeId + ' ' + (plan.bus_features ? 'business-plan' : '')">
|
||||
<div class="plan-box">
|
||||
<div class="plan-title">{{ plan.title }}</div>
|
||||
<div class="plan-price">${{ plan.price/100 }}</div>
|
||||
|
||||
<div class="callout">
|
||||
Business Plan Pricing
|
||||
</div>
|
||||
<div class="description">{{ plan.audience }}</div>
|
||||
</div>
|
||||
|
||||
<div class="all-plans">
|
||||
All business plans include all of the personal plan features, plus: <span class="business-feature">organizations</span> and <span class="business-feature">teams</span> with <span class="business-feature">delegated access</span> to the organization. All business plans have a <span class="business-feature">14-day free trial</span>.
|
||||
</div>
|
||||
<div class="features hidden-xs">
|
||||
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
|
||||
<div class="feature present"></div>
|
||||
<div class="feature present"></div>
|
||||
<div class="feature present"></div>
|
||||
<div class="feature present"></div>
|
||||
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
|
||||
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
|
||||
<div class="feature" ng-class="plan.bus_features ? 'present' : ''"></div>
|
||||
<div class="feature present"></div>
|
||||
</div>
|
||||
|
||||
<div class="features visible-xs">
|
||||
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
|
||||
<div class="feature present">Unlimited Public Repositories</div>
|
||||
<div class="feature present">SSL Encryption</div>
|
||||
<div class="feature present">Robot accounts</div>
|
||||
<div class="feature present">Dockerfile Build</div>
|
||||
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Teams</div>
|
||||
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Logging</div>
|
||||
<div class="feature" ng-class="plan.bus_features ? 'present' : 'notpresent'">Invoice History</div>
|
||||
<div class="feature present">14-Day Free Trial</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-block" ng-class="plan.bus_features ? 'btn-success' : 'btn-primary'"
|
||||
ng-click="buyNow(plan.stripeId)">Start <span class="hidden-sm-inline">Free</span> Trial</button>
|
||||
|
||||
<div class="row plans-list">
|
||||
<div class="col-xs-0 col-lg-1"></div>
|
||||
<div class="col-lg-2 col-xs-4 plan-container" ng-repeat="plan in plans.business">
|
||||
<div class="plan business-plan" ng-class="plan.stripeId">
|
||||
<div class="plan-title">{{ plan.title }}</div>
|
||||
<div class="plan-price">${{ plan.price/100 }}</div>
|
||||
<div class="count"><b>{{ plan.privateRepos }}</b> private repositories</div>
|
||||
<div class="description">{{ plan.audience }}</div>
|
||||
<div class="smaller">SSL secured connections</div>
|
||||
<button class="btn btn-success btn-block" ng-click="createOrg(plan.stripeId)">Sign Up Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -82,7 +145,7 @@
|
|||
<div class="user-setup" signed-in="signedIn()" redirect-url="'/plans/'"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal" ng-click="cancelNotedPlan()">Close</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
|
|
|
@ -1,13 +1,5 @@
|
|||
|
||||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="container" ng-show="!loading && (!repo || !permissions)">
|
||||
No repository found
|
||||
</div>
|
||||
|
||||
<div class="container repo repo-admin" ng-show="!loading && repo && permissions">
|
||||
<div class="resource-view" resource="repository" error-message="'No repository found'"></div>
|
||||
<div class="container repo repo-admin" ng-show="repo">
|
||||
<div class="header row">
|
||||
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name }}" class="back"><i class="fa fa-chevron-left"></i></a>
|
||||
<h3>
|
||||
|
@ -35,7 +27,7 @@
|
|||
<div id="logs" class="tab-pane">
|
||||
<div class="logs-view" repository="repo" visible="logsShown"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Permissions tab -->
|
||||
<div id="permissions" class="tab-pane active">
|
||||
<!-- User Access Permissions -->
|
||||
|
@ -137,7 +129,7 @@
|
|||
|
||||
<tr>
|
||||
<td class="admin-search">
|
||||
<input type="text" class="form-control" placeholder="New token description" ng-model="newToken.friendlyName"required>
|
||||
<input type="text" class="form-control" placeholder="New token description" ng-model="newToken.friendlyName" required>
|
||||
</td>
|
||||
<td class="admin-search">
|
||||
<button type="submit" ng-disabled="createTokenForm.$invalid" class="btn btn-sm btn-default">Create</button>
|
||||
|
@ -156,39 +148,44 @@
|
|||
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="URLs which will be invoked with an HTTP POST and JSON payload when a successful push to the repository occurs."></i>
|
||||
</div>
|
||||
|
||||
<div class="panel-body" ng-show="webhooksLoading">
|
||||
Loading webhooks: <i class="fa fa-spinner fa-spin fa-2x" style="vertical-align: middle; margin-left: 4px"></i>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="resource-view" resource="webhooksResource" error-message="'Could not load webhooks'">
|
||||
<table class="permissions">
|
||||
<thead>
|
||||
<tr>
|
||||
<td style="width: 500px;">Webhook URL</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr ng-repeat="webhook in webhooks">
|
||||
<td>{{ webhook.parameters.url }}</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0">
|
||||
<span class="delete-ui-button" ng-click="deleteWebhook(webhook)"><button class="btn btn-danger">Delete</button></span>
|
||||
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Webhook"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel-body" ng-show="!webhooksLoading">
|
||||
<table class="permissions" ng-form="newWebhookForm">
|
||||
<thead>
|
||||
<tr>
|
||||
<td style="width: 500px;">Webhook URL</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr ng-repeat="webhook in webhooks">
|
||||
<td>{{ webhook.parameters.url }}</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0">
|
||||
<span class="delete-ui-button" ng-click="deleteWebhook(webhook)"><button class="btn btn-danger">Delete</button></span>
|
||||
<i class="fa fa-times" bs-tooltip="tooltip.title" data-placement="right" title="Delete Webhook"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<input type="url" class="form-control" placeholder="New webhook url..." ng-model="newWebhook.url" required>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-primary" type="submit" ng-click="createWebhook()">Create</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<form name="createWebhookForm" ng-submit="createWebhook()">
|
||||
<table class="permissions">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 500px;">
|
||||
<input type="url" class="form-control" placeholder="New webhook url..." ng-model="newWebhook.url" required>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-primary" type="submit" ng-disabled="createWebhookForm.$invalid">Create</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
|
||||
<div class="right-info">
|
||||
Quay will <b>POST</b> to these webhooks whenever a push occurs. See the <a href="/guide">User Guide</a> for more information.
|
||||
|
@ -240,132 +237,136 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="docker-auth-dialog" username="'$token'" token="shownToken.code"
|
||||
shown="!!shownToken" counter="shownTokenCounter">
|
||||
<i class="fa fa-key"></i> {{ shownToken.friendlyName }}
|
||||
</div>
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="cannotchangeModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</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">×</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">×</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">×</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">×</button>
|
||||
<h4 class="modal-title">Delete Repository?</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Are you <b>absolutely, positively</b> sure you would like to delete this repository? This <b>cannot be undone</b>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" ng-click="deleteRepo()">Delete Repository</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="confirmaddoutsideModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Add User?</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
The selected user is outside of your organization. Are you sure you want to grant the user access to this repository?
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" ng-click="grantRole()">Yes, I'm sure</button>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
<!-- Auth dialog -->
|
||||
<div class="docker-auth-dialog" username="'$token'" token="shownToken.code"
|
||||
shown="!!shownToken" counter="shownTokenCounter">
|
||||
<i class="fa fa-key"></i> {{ shownToken.friendlyName }}
|
||||
</div>
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="cannotchangeModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</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">×</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">×</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">×</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">×</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">×</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 -->
|
||||
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="container" ng-show="!loading">
|
||||
<div class="container">
|
||||
<div class="repo-list" ng-show="!user.anonymous">
|
||||
<div ng-class="user.organizations.length ? 'section-header' : ''">
|
||||
<div class="button-bar-right">
|
||||
|
@ -26,31 +22,47 @@
|
|||
|
||||
<h3 ng-show="namespace == user.username">Your Repositories</h3>
|
||||
<h3 ng-show="namespace != user.username">Repositories</h3>
|
||||
|
||||
<div ng-show="user_repositories.length > 0">
|
||||
<div class="repo-listing" ng-repeat="repository in user_repositories">
|
||||
<span class="repo-circle no-background" repo="repository"></span>
|
||||
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
|
||||
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
|
||||
|
||||
<div class="resource-view" resource="user_repositories">
|
||||
<!-- User/Org has repositories -->
|
||||
<div ng-show="user_repositories.value.length > 0">
|
||||
<div class="repo-listing" ng-repeat="repository in user_repositories.value">
|
||||
<span class="repo-circle no-background" repo="repository"></span>
|
||||
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
|
||||
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User/Org has no repositories -->
|
||||
<div ng-show="user_repositories.value.length == 0" style="padding:20px;">
|
||||
<div class="alert alert-info">
|
||||
<h4 ng-show="namespace == user.username">You don't have any repositories yet!</h4>
|
||||
<h4 ng-show="namespace != user.username">This organization doesn't have any repositories, or you have not been provided access.</h4>
|
||||
<a href="/guide"><b>Click here</b> to learn how to create a repository</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="user_repositories.length == 0" style="padding:20px;">
|
||||
<div class="alert alert-info">
|
||||
<h4 ng-show="namespace == user.username">You don't have any repositories yet!</h4>
|
||||
<h4 ng-show="namespace != user.username">This organization doesn't have any repositories, or you have not been provided access.</h4>
|
||||
<a href="/guide"><b>Click here</b> to learn how to create a repository</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repo-list">
|
||||
<h3>Top Public Repositories</h3>
|
||||
<div class="repo-listing" ng-repeat="repository in public_repositories">
|
||||
<span class="repo-circle no-background" repo="repository"></span>
|
||||
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
|
||||
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
|
||||
<div class="resource-view" resource="public_repositories">
|
||||
<div class="repo-listing" ng-repeat="repository in public_repositories.value">
|
||||
<span class="repo-circle no-background" repo="repository"></span>
|
||||
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}">{{repository.namespace}}/{{repository.name}}</a>
|
||||
<div class="description markdown-view" content="repository.description" first-line-only="true"></div>
|
||||
</div>
|
||||
<div class="page-controls">
|
||||
<button class="btn btn-default" title="Previous Page" bs-tooltip="title" ng-show="page > 1"
|
||||
ng-click="movePublicPage(-1)">
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
<button class="btn btn-default" title="Next Page" bs-tooltip="title" ng-show="page < publicPageCount"
|
||||
ng-click="movePublicPage(1)">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,47 +1,42 @@
|
|||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
<div class="resource-view" resource="orgResource" error-message="'No matching organization'">
|
||||
<div class="team-view container">
|
||||
<div class="organization-header" organization="organization" team-name="teamname"></div>
|
||||
|
||||
<div class="resource-view" resource="membersResource" error-message="'No matching team found'">
|
||||
<div class="description markdown-input" content="team.description" can-write="organization.is_admin"
|
||||
content-changed="updateForDescription" field-title="'team description'"></div>
|
||||
|
||||
<div class="loading" ng-show="!loading && !organization">
|
||||
No matching team found
|
||||
</div>
|
||||
|
||||
<div class="team-view container" ng-show="!loading && organization">
|
||||
<div class="organization-header" organization="organization" team-name="teamname"></div>
|
||||
|
||||
<div class="description markdown-input" content="team.description" can-write="organization.is_admin"
|
||||
content-changed="updateForDescription" field-title="'team description'"></div>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Team Members
|
||||
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Users that inherit all permissions delegated to this team"></i>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table class="permissions">
|
||||
<tr ng-repeat="(name, member) in members">
|
||||
<td class="user entity">
|
||||
<span class="entity-reference" name="member.username" isrobot="member.is_robot"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0" title="Remove User" ng-show="canEditMembers">
|
||||
<span class="delete-ui-button" ng-click="removeMember(member.username)"><button class="btn btn-danger">Remove</button></span>
|
||||
<i class="fa fa-times"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr ng-show="canEditMembers">
|
||||
<td colspan="2">
|
||||
<span class="entity-search" namespace="orgname" include-teams="false" input-title="'Add a user...'"
|
||||
entity-selected="addNewMember" is-organization="true"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">Team Members
|
||||
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="Users that inherit all permissions delegated to this team"></i>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table class="permissions">
|
||||
<tr ng-repeat="(name, member) in members">
|
||||
<td class="user entity">
|
||||
<span class="entity-reference" name="member.username" isrobot="member.is_robot"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="delete-ui" tabindex="0" title="Remove User" ng-show="canEditMembers">
|
||||
<span class="delete-ui-button" ng-click="removeMember(member.username)"><button class="btn btn-danger">Remove</button></span>
|
||||
<i class="fa fa-times"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr ng-show="canEditMembers">
|
||||
<td colspan="2">
|
||||
<span class="entity-search" namespace="orgname" include-teams="false" input-title="'Add a user...'"
|
||||
entity-selected="addNewMember" is-organization="true"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="cannotChangeTeamModal">
|
||||
<div class="modal-dialog">
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
<div class="loading" ng-show="!user">
|
||||
<div class="quay-spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="loading" ng-show="!loading && !user">
|
||||
<div class="loading" ng-show="user.anonymous">
|
||||
No matching user found
|
||||
</div>
|
||||
|
||||
<div class="user-admin container" ng-show="!loading && user">
|
||||
<div class="user-admin container" ng-show="!user.anonymous">
|
||||
<div class="row">
|
||||
<div class="organization-header-element">
|
||||
<img src="//www.gravatar.com/avatar/{{ user.gravatar }}?s=24&d=identicon">
|
||||
|
@ -27,16 +27,24 @@
|
|||
<div class="col-md-2">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
|
||||
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billing">Billing Options</a></li>
|
||||
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing Options</a></li>
|
||||
<li ng-show="hasPaidBusinessPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billing" ng-click="loadInvoices()">Billing History</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#robots">Robot Accounts</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Set Password</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#password">Change Password</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#github">GitHub Login</a></li>
|
||||
<li ng-show="hasPaidBusinessPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#logs" ng-click="loadLogs()">Usage Logs</a></li>
|
||||
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#migrate" id="migrateTab">Convert to Organization</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="col-md-10">
|
||||
<div class="tab-content">
|
||||
<div class="tab-content">
|
||||
<!-- Logs tab -->
|
||||
<div id="logs" class="tab-pane">
|
||||
<div class="logs-view" user="user" visible="logsShown"></div>
|
||||
</div>
|
||||
|
||||
<!-- Plans tab -->
|
||||
<div id="plan" class="tab-pane active">
|
||||
<div class="plan-manager" user="user.username" ready-for-plan="readyForPlan()" plan-changed="planChanged(plan)"></div>
|
||||
|
@ -45,18 +53,48 @@
|
|||
<!-- Change password tab -->
|
||||
<div id="password" class="tab-pane">
|
||||
<div class="loading" ng-show="updatingUser">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
<div class="quay-spinner 3x"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<form class="form-change-pw col-md-6" name="changePasswordForm" ng-submit="changePassword()" data-trigger="manual"
|
||||
data-content="{{ changePasswordError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering">
|
||||
<input type="password" class="form-control" placeholder="Your new password" ng-model="user.password" required>
|
||||
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="user.repeatPassword"
|
||||
match="user.password" required>
|
||||
<button class="btn btn-danger" ng-disabled="changePasswordForm.$invalid" type="submit"
|
||||
analytics-on analytics-event="register">Change Password</button>
|
||||
<div class="panel">
|
||||
<div class="panel-title">Change Password</div>
|
||||
|
||||
<span class="help-block" ng-show="changePasswordSuccess">Password changed successfully</span>
|
||||
</form>
|
||||
|
||||
<div ng-show="!updatingUser" class="panel-body">
|
||||
<form class="form-change-pw col-md-6" name="changePasswordForm" ng-submit="changePassword()" data-trigger="manual"
|
||||
data-content="{{ changePasswordError }}" data-placement="right" ng-show="!awaitingConfirmation && !registering">
|
||||
<input type="password" class="form-control" placeholder="Your new password" ng-model="cuser.password" required>
|
||||
<input type="password" class="form-control" placeholder="Verify your new password" ng-model="cuser.repeatPassword"
|
||||
match="cuser.password" required>
|
||||
<button class="btn btn-danger" ng-disabled="changePasswordForm.$invalid" type="submit"
|
||||
analytics-on analytics-event="change_pass">Change Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="github" class="tab-pane">
|
||||
<div class="loading" ng-show="!cuser">
|
||||
<div class="quay-spinner 3x"></div>
|
||||
</div>
|
||||
<div class="row" ng-show="cuser">
|
||||
<div class="panel">
|
||||
<div class="panel-title">GitHub Login</div>
|
||||
<div class="panel-body">
|
||||
<div ng-show="githubLogin" class="lead col-md-8">
|
||||
<span class="fa-stack">
|
||||
<i class="fa fa-circle fa-stack-2x check-green"></i>
|
||||
<i class="fa fa-check fa-stack-1x fa-inverse"></i>
|
||||
</span>
|
||||
This account is connected with GitHub account: <b>{{githubLogin}}</b>
|
||||
</div>
|
||||
<div ng-show="!githubLogin" class="col-md-8">
|
||||
<a href="https://github.com/login/oauth/authorize?client_id={{ githubClientId }}&scope=user:email{{ github_state_clause }}&redirect_uri={{ githubRedirectUri }}/attach" class="btn btn-primary"><i class="fa fa-github fa-lg"></i> Connect with GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -66,10 +104,15 @@
|
|||
</div>
|
||||
|
||||
<!-- Billing options tab -->
|
||||
<div id="billing" class="tab-pane">
|
||||
<div id="billingoptions" class="tab-pane">
|
||||
<div class="billing-options" user="user"></div>
|
||||
</div>
|
||||
|
||||
<!-- Billing History tab -->
|
||||
<div id="billing" class="tab-pane">
|
||||
<div class="billing-invoices" user="user" visible="invoicesShown"></div>
|
||||
</div>
|
||||
|
||||
<!-- Convert to organization tab -->
|
||||
<div id="migrate" class="tab-pane">
|
||||
<!-- Step 0 -->
|
||||
|
@ -83,11 +126,11 @@
|
|||
</div>
|
||||
|
||||
<div class="panel-body" ng-show="user.organizations.length == 0">
|
||||
<div class="alert alert-danger">
|
||||
Converting a user account into an organization <b>cannot be undone</b>.<br> Here be many fire-breathing dragons!
|
||||
<div class="alert alert-warning">
|
||||
Note: Converting a user account into an organization <b>cannot be undone</b>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-danger" ng-click="showConvertForm()">Start conversion process</button>
|
||||
<button class="btn btn-primary" ng-click="showConvertForm()">Start conversion process</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -110,7 +153,7 @@
|
|||
ng-model="org.adminUser" required autofocus>
|
||||
<input id="adminPassword" name="adminPassword" type="password" class="form-control" placeholder="Admin Password"
|
||||
ng-model="org.adminPassword" required>
|
||||
<span class="description">The username and password for an <b>existing account</b> that will become administrator of the organization</span>
|
||||
<span class="description">The username and password for the account that will become administrator of the organization</span>
|
||||
</div>
|
||||
|
||||
<!-- Plans Table -->
|
||||
|
@ -120,7 +163,8 @@
|
|||
</div>
|
||||
|
||||
<div class="button-bar">
|
||||
<button class="btn btn-large btn-danger" type="submit" ng-disabled="convertForm.$invalid || !org.plan">
|
||||
<button class="btn btn-large btn-danger" type="submit" ng-disabled="convertForm.$invalid || !org.plan"
|
||||
analytics-on analytics-event="convert_to_organization">
|
||||
Convert To Organization
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -1,193 +1,276 @@
|
|||
<div class="container" ng-show="!loading && !repo">
|
||||
No repository found
|
||||
<div id="tagContextMenu" class="dropdown clearfix" tabindex="-1">
|
||||
<ul class="dropdown-menu" role="menu" aria-labelledby="dropdownMenu">
|
||||
<li><a tabindex="-1" href="javascript:void(0)" ng-click="askDeleteTag(currentMenuTag)">Delete Tag</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="loading" ng-show="loading">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
<div class="resource-view" resource="repository" error-message="'No Repository Found'">
|
||||
<div class="container repo">
|
||||
<!-- Repo Header -->
|
||||
<div class="header">
|
||||
<h3>
|
||||
<span class="repo-circle" repo="repo"></span>
|
||||
<span style="color: #aaa;"> {{repo.namespace}}</span> <span style="color: #ccc">/</span> {{repo.name}}
|
||||
<span class="settings-cog" ng-show="repo.can_admin" title="Repository Settings" bs-tooltip="tooltip.title" data-placement="bottom">
|
||||
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name + '/admin' }}">
|
||||
<i class="fa fa-cog fa-lg"></i>
|
||||
</a>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<!-- Pull command -->
|
||||
<div class="pull-command visible-md visible-lg" style="display: none;">
|
||||
<span class="pull-command-title">Pull repository:</span>
|
||||
<div class="pull-container">
|
||||
<div class="input-group">
|
||||
<input id="pull-text" type="text" class="form-control" value="{{ 'docker pull quay.io/' + repo.namespace + '/' + repo.name }}" readonly>
|
||||
<span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="pull-text">
|
||||
<i class="fa fa-copy"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container repo" ng-show="!loading && repo">
|
||||
<!-- Repo Header -->
|
||||
<div class="header">
|
||||
<h3>
|
||||
<span class="repo-circle" repo="repo"></span>
|
||||
|
||||
<span style="color: #aaa;"> {{repo.namespace}}</span> <span style="color: #ccc">/</span> {{repo.name}}
|
||||
|
||||
<span class="settings-cog" ng-show="repo.can_admin" title="Repository Settings" bs-tooltip="tooltip.title" data-placement="bottom">
|
||||
<a href="{{ '/repository/' + repo.namespace + '/' + repo.name + '/admin' }}">
|
||||
<i class="fa fa-cog fa-lg"></i>
|
||||
</a>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<!-- Pull command -->
|
||||
<div class="pull-command visible-md visible-lg" style="display: none;">
|
||||
<span class="pull-command-title">Pull repository:</span>
|
||||
<div class="pull-container">
|
||||
<div class="input-group">
|
||||
<input id="pull-text" type="text" class="form-control" value="{{ 'docker pull quay.io/' + repo.namespace + '/' + repo.name }}" readonly>
|
||||
<span id="copyClipboard" class="input-group-addon" title="Copy to Clipboard" data-clipboard-target="pull-text">
|
||||
<i class="fa fa-copy"></i>
|
||||
</span>
|
||||
<div id="clipboardCopied" class="hovering" style="display: none">
|
||||
Copied to clipboard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="clipboardCopied" class="hovering" style="display: none">
|
||||
Copied to clipboard
|
||||
<!-- Status boxes -->
|
||||
<div class="status-boxes">
|
||||
<div id="buildInfoBox" class="status-box" ng-show="repo.is_building"
|
||||
bs-popover="'static/partials/build-status-item.html'" data-placement="bottom">
|
||||
<span class="title">
|
||||
<span class="quay-spinner"></span>
|
||||
<b>Building Images</b>
|
||||
</span>
|
||||
<span class="count" ng-class="buildsInfo ? 'visible' : ''"><span>{{ buildsInfo ? buildsInfo.length : '-' }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status boxes -->
|
||||
<div class="status-boxes">
|
||||
<div id="buildInfoBox" class="status-box" ng-show="repo.is_building"
|
||||
bs-popover="'static/partials/build-status-item.html'" data-placement="bottom">
|
||||
<span class="title">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
<b>Building Images</b>
|
||||
</span>
|
||||
<span class="count" ng-class="buildsInfo ? 'visible' : ''"><span>{{ buildsInfo ? buildsInfo.length : '-' }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Description -->
|
||||
<div class="description markdown-input" content="repo.description" can-write="repo.can_write"
|
||||
content-changed="updateForDescription" field-title="'repository description'"></div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="description markdown-input" content="repo.description" can-write="repo.can_write"
|
||||
content-changed="updateForDescription" field-title="'repository description'"></div>
|
||||
<!-- Empty message -->
|
||||
<div class="repo-content" ng-show="!currentTag.image && !currentImage && !repo.is_building">
|
||||
<div class="empty-message">
|
||||
This repository is empty
|
||||
</div>
|
||||
|
||||
<!-- Empty message -->
|
||||
<div class="repo-content" ng-show="!currentTag.image && !repo.is_building">
|
||||
<div class="empty-message">
|
||||
This repository is empty
|
||||
</div>
|
||||
|
||||
<div class="empty-description" ng-show="repo.can_write">
|
||||
To push images to this repository:<br><br>
|
||||
<pre>sudo docker tag <i>0u123imageidgoeshere</i> quay.io/{{repo.namespace}}/{{repo.name}}
|
||||
<div class="empty-description" ng-show="repo.can_write">
|
||||
To push images to this repository:<br><br>
|
||||
<pre>sudo docker tag <i>0u123imageidgoeshere</i> quay.io/{{repo.namespace}}/{{repo.name}}
|
||||
sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="repo-content" ng-show="!currentTag.image && repo.is_building">
|
||||
<div class="empty-message">Your build is currently processing, if this takes longer than an hour, please contact <a href="mailto:support@quay.io">Quay.io support</a></div>
|
||||
</div>
|
||||
<div class="repo-content" ng-show="!currentTag.image && repo.is_building">
|
||||
<div class="empty-message">
|
||||
Your build is currently processing, if this takes longer than an hour, please contact <a href="mailto:support@quay.io">Quay.io support</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content view -->
|
||||
<div class="repo-content" ng-show="currentTag.image">
|
||||
<!-- Image History -->
|
||||
<div id="image-history" style="max-height: 10px;">
|
||||
<div class="row">
|
||||
<!-- Tree View container -->
|
||||
<div class="col-md-8">
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<!-- Tag dropdown -->
|
||||
<div class="tag-dropdown dropdown" title="Tags" bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-tag"><span class="tag-count">{{getTagCount(repo)}}</span></i>
|
||||
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentTag.name}} <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li ng-repeat="tag in repo.tags">
|
||||
<a href="javascript:void(0)" ng-click="setTag(tag.name)">{{tag.name}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Content view -->
|
||||
<div class="repo-content" ng-show="currentTag.image || currentImage">
|
||||
<!-- Image History -->
|
||||
<div id="image-history" style="max-height: 10px;">
|
||||
<div class="row">
|
||||
<!-- Tree View container -->
|
||||
<div class="col-md-8">
|
||||
<div class="panel panel-default">
|
||||
<!-- Image history tree -->
|
||||
<div class="resource-view" resource="imageHistory">
|
||||
<div id="image-history-container" onresize="tree.notifyResized()"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Side Panel -->
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<!-- Dropdown -->
|
||||
<div class="tag-dropdown dropdown" data-placement="top">
|
||||
<i class="fa fa-tag" ng-show="currentTag"></i>
|
||||
<i class="fa fa-archive" ng-show="!currentTag"></i>
|
||||
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentTag ? currentTag.name : currentImage.id.substr(0, 12)}} <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li ng-repeat="tag in repo.tags">
|
||||
<a href="javascript:void(0)" ng-click="setTag(tag.name, true)">
|
||||
<i class="fa fa-tag"></i>{{tag.name}}
|
||||
</a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li ng-repeat="image in imageHistory.value">
|
||||
<a href="javascript:void(0)" ng-click="setImage(image.id, true)">
|
||||
{{image.id.substr(0, 12)}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="right-tag-controls">
|
||||
<i class="fa fa-tag" title="Tags" bs-tooltip="title">
|
||||
<span class="tag-count">{{getTagCount(repo)}}</span>
|
||||
</i>
|
||||
<i class="fa fa-archive" title="Images" bs-tooltip="title">
|
||||
<span class="tag-count">{{imageHistory.value.length}}</span>
|
||||
</i>
|
||||
</span>
|
||||
</div>
|
||||
<span class="right-title">Tags</span>
|
||||
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<!-- Current Tag -->
|
||||
<div id="current-tag" ng-show="currentTag">
|
||||
<dl class="dl-normal">
|
||||
<dt>Last Modified</dt>
|
||||
<dd am-time-ago="parseDate(currentTag.image.created)"></dd>
|
||||
<dt>Total Compressed Size</dt>
|
||||
<dd><span class="context-tooltip"
|
||||
title="The amount of data sent between Docker and Quay.io when pushing/pulling"
|
||||
bs-tooltip="tooltip.title" data-container="body">{{ getTotalSize(currentTag) | bytes }}</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<div class="tag-image-sizes">
|
||||
<div class="tag-image-size" ng-repeat="image in getImagesForTagBySize(currentTag) | limitTo: 10">
|
||||
<span class="size-limiter">
|
||||
<span class="size-bar" style="{{ 'width:' + (image.size / getTotalSize(currentTag)) * 100 + '%' }}"
|
||||
bs-tooltip="image.size | bytes"></span>
|
||||
</span>
|
||||
<span class="size-title"><a href="javascript:void(0)" ng-click="setImage(image.id, true)">{{ image.id.substr(0, 12) }}</a></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image history loading -->
|
||||
<div ng-hide="imageHistory" style="padding: 10px; text-align: center;">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
<div class="control-bar" ng-show="repo.can_admin">
|
||||
<button class="btn btn-default" ng-click="askDeleteTag(currentTag.name)">
|
||||
Delete Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tree View itself -->
|
||||
<div id="image-history-container" onresize="tree.notifyResized()"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Current Image -->
|
||||
<div id="current-image" ng-show="currentImage && !currentTag">
|
||||
<div ng-show="currentImage.comment">
|
||||
<blockquote style="margin-top: 10px;">
|
||||
<span class="markdown-view" content="currentImage.comment"></span>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
<!-- Side Panel -->
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<!-- Image dropdown -->
|
||||
<div class="tag-dropdown dropdown" title="Images" bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-archive"><span class="tag-count">{{imageHistory.length}}</span></i>
|
||||
<a href="javascript:void(0)" class="dropdown-toggle" data-toggle="dropdown">{{currentImage.id.substr(0, 12)}} <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li ng-repeat="image in imageHistory">
|
||||
<a href="javascript:void(0)" ng-click="setImage(image)">{{image.id.substr(0, 12)}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<dl class="dl-normal">
|
||||
<dt>Created</dt>
|
||||
<dd am-time-ago="parseDate(currentImage.created)"></dd>
|
||||
<dt>Image ID</dt>
|
||||
<dd><a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">{{ currentImage.id }}</a></dd>
|
||||
<dt>Compressed Image Size</dt>
|
||||
<dd><span class="context-tooltip"
|
||||
title="The amount of data sent between Docker and Quay.io when pushing/pulling"
|
||||
bs-tooltip="tooltip.title" data-container="body">{{ currentImage.size | bytes }}</span>
|
||||
</dd>
|
||||
<dt ng-show="currentImage.command && currentImage.command.length">Command</dt>
|
||||
<dd ng-show="currentImage.command && currentImage.command.length" class="codetooltipcontainer">
|
||||
<pre class="formatted-command trimmed"
|
||||
bs-tooltip="getTooltipCommand(currentImage)"
|
||||
data-placement="top">{{ getFormattedCommand(currentImage) }}</pre>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<!-- Image changes loading -->
|
||||
<div class="resource-view" resource="currentImageChangeResource">
|
||||
<div class="changes-container small-changes-container"
|
||||
ng-show="currentImageChanges.changed.length || currentImageChanges.added.length || currentImageChanges.removed.length">
|
||||
<div class="changes-count-container accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseChanges">
|
||||
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" title="Files Added"
|
||||
bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-plus-square"></i>
|
||||
<b>{{currentImageChanges.added.length}}</b>
|
||||
</span>
|
||||
<span class="change-count removed" ng-show="currentImageChanges.removed.length > 0" title="Files Removed"
|
||||
bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-minus-square"></i>
|
||||
<b>{{currentImageChanges.removed.length}}</b>
|
||||
</span>
|
||||
<span class="change-count changed" ng-show="currentImageChanges.changed.length > 0" title="Files Changed"
|
||||
bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-pencil-square"></i>
|
||||
<b>{{currentImageChanges.changed.length}}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="collapseChanges" class="panel-collapse collapse in">
|
||||
<div class="well well-sm">
|
||||
<div class="change added" ng-repeat="file in currentImageChanges.added | limitTo:5">
|
||||
<i class="fa fa-plus-square"></i>
|
||||
<span title="{{file}}">{{file}}</span>
|
||||
</div>
|
||||
<div class="change removed" ng-repeat="file in currentImageChanges.removed | limitTo:5">
|
||||
<i class="fa fa-minus-square"></i>
|
||||
<span title="{{file}}">{{file}}</span>
|
||||
</div>
|
||||
<div class="change changed" ng-repeat="file in currentImageChanges.changed | limitTo:5">
|
||||
<i class="fa fa-pencil-square"></i>
|
||||
<span title="{{file}}">{{file}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-changes" ng-show="getMoreCount(currentImageChanges) > 0">
|
||||
<a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">
|
||||
And {{getMoreCount(currentImageChanges)}} more...
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="right-title">Image</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div id="current-image">
|
||||
<div ng-show="currentImage.comment">
|
||||
<blockquote style="margin-top: 10px;">
|
||||
<span class="markdown-view" content="currentImage.comment"></span>
|
||||
</blockquote>
|
||||
</div>
|
||||
|
||||
<dl class="dl-normal">
|
||||
<dt>Created</dt>
|
||||
<dd am-time-ago="parseDate(currentImage.created)"></dd>
|
||||
<dt>Image ID</dt>
|
||||
<dd><a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">{{ currentImage.id }}</a></dd>
|
||||
</dl>
|
||||
|
||||
<!-- Image changes loading -->
|
||||
<div ng-hide="currentImageChanges">
|
||||
<i class="fa fa-spinner fa-spin fa-3x"></i>
|
||||
</div>
|
||||
|
||||
<div class="changes-container small-changes-container"
|
||||
ng-show="currentImageChanges.changed.length || currentImageChanges.added.length || currentImageChanges.removed.length">
|
||||
<div class="changes-count-container accordion-toggle" data-toggle="collapse" data-parent="#accordion" data-target="#collapseChanges">
|
||||
<span class="change-count added" ng-show="currentImageChanges.added.length > 0" title="Files Added"
|
||||
bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-plus-square"></i>
|
||||
<b>{{currentImageChanges.added.length}}</b>
|
||||
</span>
|
||||
<span class="change-count removed" ng-show="currentImageChanges.removed.length > 0" title="Files Removed"
|
||||
bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-minus-square"></i>
|
||||
<b>{{currentImageChanges.removed.length}}</b>
|
||||
</span>
|
||||
<span class="change-count changed" ng-show="currentImageChanges.changed.length > 0" title="Files Changed"
|
||||
bs-tooltip="tooltip.title" data-placement="top">
|
||||
<i class="fa fa-pencil-square"></i>
|
||||
<b>{{currentImageChanges.changed.length}}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="collapseChanges" class="panel-collapse collapse in">
|
||||
<div class="well well-sm">
|
||||
<div class="change added" ng-repeat="file in currentImageChanges.added | limitTo:5">
|
||||
<i class="fa fa-plus-square"></i>
|
||||
<span title="{{file}}">{{file}}</span>
|
||||
</div>
|
||||
<div class="change removed" ng-repeat="file in currentImageChanges.removed | limitTo:5">
|
||||
<i class="fa fa-minus-square"></i>
|
||||
<span title="{{file}}">{{file}}</span>
|
||||
</div>
|
||||
<div class="change changed" ng-repeat="file in currentImageChanges.changed | limitTo:5">
|
||||
<i class="fa fa-pencil-square"></i>
|
||||
<span title="{{file}}">{{file}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="more-changes" ng-show="getMoreCount(currentImageChanges) > 0">
|
||||
<a href="{{'/repository/' + repo.namespace + '/' + repo.name + '/image/' + currentImage.id}}">And {{getMoreCount(currentImageChanges)}} more...</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="confirmdeleteTagModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</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 -->
|
||||
|
||||
|
|
|
@ -34,6 +34,10 @@ class Storage(object):
|
|||
namespace,
|
||||
repository)
|
||||
|
||||
def image_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/'.format(self.images, namespace, repository,
|
||||
image_id)
|
||||
|
||||
def image_json_path(self, namespace, repository, image_id):
|
||||
return '{0}/{1}/{2}/{3}/json'.format(self.images, namespace,
|
||||
repository, image_id)
|
||||
|
|
|
@ -11,6 +11,8 @@
|
|||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link rel="stylesheet" href="/static/lib/loading-bar.css">
|
||||
|
||||
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/font-awesome/4.0.0/css/font-awesome.css">
|
||||
<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.no-icons.min.css">
|
||||
<link href='//fonts.googleapis.com/css?family=Droid+Sans:400,700' rel='stylesheet' type='text/css'>
|
||||
|
@ -40,13 +42,16 @@
|
|||
|
||||
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.1/angular.min.js"></script>
|
||||
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.1/angular-route.min.js"></script>
|
||||
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.1/angular-sanitize.min.js"></script>
|
||||
|
||||
<script src="//cdn.jsdelivr.net/underscorejs/1.5.2/underscore-min.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/restangular/1.1.3/restangular.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/restangular/1.2.0/restangular.min.js"></script>
|
||||
|
||||
<script src="static/lib/loading-bar.js"></script>
|
||||
<script src="static/lib/angular-strap.min.js"></script>
|
||||
<script src="static/lib/angulartics.js"></script>
|
||||
<script src="static/lib/angulartics-mixpanel.js"></script>
|
||||
<script src="static/lib/angulartics-google-analytics.js"></script>
|
||||
|
||||
<script src="static/lib/angular-moment.min.js"></script>
|
||||
<script src="static/lib/angular-cookies.min.js"></script>
|
||||
|
@ -61,6 +66,10 @@
|
|||
|
||||
{% endblock %}
|
||||
|
||||
<script type="text/javascript">
|
||||
window.__endpoints = {{ route_data|safe }}.endpoints;
|
||||
</script>
|
||||
|
||||
<script src="static/js/app.js"></script>
|
||||
<script src="static/js/controllers.js"></script>
|
||||
<script src="static/js/graphing.js"></script>
|
||||
|
@ -72,6 +81,19 @@ var isProd = document.location.hostname === 'quay.io';
|
|||
typeof d?c=b[d]=[]:d="mixpanel";c.people=c.people||[];c.toString=function(b){var a="mixpanel";"mixpanel"!==d&&(a+="."+d);b||(a+=" (stub)");return a};c.people.toString=function(){return c.toString(1)+".people (stub)"};i="disable track track_pageview track_links track_forms register register_once alias unregister identify name_tag set_config people.set people.set_once people.increment people.append people.track_charge people.clear_charges people.delete_user".split(" ");for(g=0;g<i.length;g++)f(c,i[g]);
|
||||
b._i.push([a,e,d])};b.__SV=1.2}})(document,window.mixpanel||[]);
|
||||
mixpanel.init(isProd ? "50ff2b2569faa3a51c8f5724922ffb7e" : "38014a0f27e7bdc3ff8cc7cc29c869f9", { track_pageview : false, debug: !isProd });</script><!-- end Mixpanel -->
|
||||
|
||||
<!-- start analytics --><script>
|
||||
/*
|
||||
var isProd = document.location.hostname === 'quay.io';
|
||||
|
||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
|
||||
|
||||
ga('create', isProd ? 'UA-34988886-5' : 'UA-34988886-4', 'quay.io');
|
||||
*/
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div ng-class="!fixFooter ? 'wrapper' : ''">
|
||||
|
@ -85,27 +107,46 @@ mixpanel.init(isProd ? "50ff2b2569faa3a51c8f5724922ffb7e" : "38014a0f27e7bdc3ff8
|
|||
</div>
|
||||
|
||||
<div class="footer-container" ng-class="fixFooter ? 'fixed' : ''">
|
||||
<nav class="page-footer visible-lg visible-md">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<ul>
|
||||
<li><span class="copyright">©2013 DevTable, LLC</span></li>
|
||||
<li><a href="http://blog.devtable.com/">Blog</a></li>
|
||||
<li><a href="/tos" target="_self">Terms of Service</a></li>
|
||||
<li><a href="/privacy" target="_self">Privacy Policy</a></li>
|
||||
<li><a href="/security/">Security</a></li>
|
||||
<li><b><a href="/contact/">Contact Us</a></b></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="page-footer-padder">
|
||||
<nav class="page-footer visible-lg visible-md">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<ul>
|
||||
<li><span class="copyright">©2013 DevTable, LLC</span></li>
|
||||
<li><a href="http://blog.devtable.com/">Blog</a></li>
|
||||
<li><a href="/tos" target="_self">Terms of Service</a></li>
|
||||
<li><a href="/privacy" target="_self">Privacy Policy</a></li>
|
||||
<li><a href="/security/">Security</a></li>
|
||||
<li><b><a href="/contact/">Contact Us</a></b></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 logo-container">
|
||||
<a href="https://devtable.com"><img class="dt-logo" src="/static/img/dt-logo.png"></a>
|
||||
</div>
|
||||
</div> <!-- row -->
|
||||
</nav>
|
||||
<div class="col-md-5 logo-container">
|
||||
<a href="https://devtable.com"><img class="dt-logo" src="/static/img/dt-logo.png"></a>
|
||||
</div>
|
||||
</div> <!-- row -->
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="couldnotloadModal" data-backdrop="static">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">Uh Oh...</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Something went wrong when trying to load Quay.io! Please report this to <a href="mailto:support@quay.io">support@quay.io</a>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
<!-- begin olark code -->
|
||||
{% if request.host == 'quay.io' %}
|
||||
<script data-cfasync="false" type='text/javascript'>/*<![CDATA[*/window.olark||(function(c){var f=window,d=document,l=f.location.protocol=="https:"?"https:":"http:",z=c.name,r="load";var nt=function(){
|
||||
f[z]=function(){
|
||||
(a.s=a.s||[]).push(arguments)};var a=f[z]._={
|
||||
|
@ -124,6 +165,7 @@ mixpanel.init(isProd ? "50ff2b2569faa3a51c8f5724922ffb7e" : "38014a0f27e7bdc3ff8
|
|||
loader: "static.olark.com/jsclient/loader0.js",name:"olark",methods:["configure","extend","declare","identify"]});
|
||||
/* custom configuration goes here (www.olark.com/documentation) */
|
||||
olark.identify('1189-336-10-9918');/*]]>*/</script><noscript><a href="https://www.olark.com/site/1189-336-10-9918/contact" title="Contact us" target="_blank">Questions? Feedback?</a> powered by <a href="http://www.olark.com?welcome" title="Olark live chat software">Olark live chat software</a></noscript>
|
||||
{% endif %}
|
||||
<!-- end olark code -->
|
||||
|
||||
</body>
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
|
||||
<div>
|
||||
Please register using the <a href="/">registration form</a> to continue.
|
||||
You will be able to connect your github account to your Quay.io account
|
||||
in the user settings.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -105,20 +105,20 @@ def build_specs():
|
|||
return [
|
||||
TestSpec(url_for('welcome'), 200, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('plans_list'), 200, 200, 200, 200),
|
||||
TestSpec(url_for('list_plans'), 200, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('get_logged_in_user'), 200, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('change_user_details'),
|
||||
401, 200, 200, 200).set_method('PUT'),
|
||||
|
||||
TestSpec(url_for('create_user_api'), 201, 201, 201,
|
||||
TestSpec(url_for('create_new_user'), 201, 201, 201,
|
||||
201).set_method('POST').set_data_from_obj(NEW_USER_DETAILS),
|
||||
|
||||
TestSpec(url_for('signin_api'), 200, 200, 200,
|
||||
TestSpec(url_for('signin_user'), 200, 200, 200,
|
||||
200).set_method('POST').set_data_from_obj(SIGNIN_DETAILS),
|
||||
|
||||
TestSpec(url_for('send_recovery'), 201, 201, 201,
|
||||
TestSpec(url_for('request_recovery_email'), 201, 201, 201,
|
||||
201).set_method('POST').set_data_from_obj(SEND_RECOVERY_DETAILS),
|
||||
|
||||
TestSpec(url_for('get_matching_users', prefix='dev'), 401, 200, 200, 200),
|
||||
|
@ -161,29 +161,29 @@ def build_specs():
|
|||
teamname=ORG_READERS, membername=ORG_OWNER),
|
||||
admin_code=400).set_method('DELETE'),
|
||||
|
||||
(TestSpec(url_for('create_repo_api'))
|
||||
(TestSpec(url_for('create_repo'))
|
||||
.set_method('POST')
|
||||
.set_data_from_obj(NEW_ORG_REPO_DETAILS)),
|
||||
|
||||
TestSpec(url_for('match_repos_api'), 200, 200, 200, 200),
|
||||
TestSpec(url_for('find_repos'), 200, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('list_repos_api'), 200, 200, 200, 200),
|
||||
TestSpec(url_for('list_repos'), 200, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('update_repo_api', repository=PUBLIC_REPO),
|
||||
TestSpec(url_for('update_repo', repository=PUBLIC_REPO),
|
||||
admin_code=403).set_method('PUT'),
|
||||
(TestSpec(url_for('update_repo_api', repository=ORG_REPO))
|
||||
(TestSpec(url_for('update_repo', repository=ORG_REPO))
|
||||
.set_method('PUT')
|
||||
.set_data_from_obj(UPDATE_REPO_DETAILS)),
|
||||
(TestSpec(url_for('update_repo_api', repository=PRIVATE_REPO))
|
||||
(TestSpec(url_for('update_repo', repository=PRIVATE_REPO))
|
||||
.set_method('PUT')
|
||||
.set_data_from_obj(UPDATE_REPO_DETAILS)),
|
||||
|
||||
(TestSpec(url_for('change_repo_visibility_api', repository=PUBLIC_REPO),
|
||||
(TestSpec(url_for('change_repo_visibility', repository=PUBLIC_REPO),
|
||||
admin_code=403).set_method('POST')
|
||||
.set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
|
||||
(TestSpec(url_for('change_repo_visibility_api', repository=ORG_REPO))
|
||||
(TestSpec(url_for('change_repo_visibility', repository=ORG_REPO))
|
||||
.set_method('POST').set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
|
||||
(TestSpec(url_for('change_repo_visibility_api', repository=PRIVATE_REPO))
|
||||
(TestSpec(url_for('change_repo_visibility', repository=PRIVATE_REPO))
|
||||
.set_method('POST').set_data_from_obj(CHANGE_VISIBILITY_DETAILS)),
|
||||
|
||||
TestSpec(url_for('delete_repository', repository=PUBLIC_REPO),
|
||||
|
@ -193,11 +193,11 @@ def build_specs():
|
|||
TestSpec(url_for('delete_repository', repository=PRIVATE_REPO),
|
||||
admin_code=204).set_method('DELETE'),
|
||||
|
||||
TestSpec(url_for('get_repo_api', repository=PUBLIC_REPO),
|
||||
TestSpec(url_for('get_repo', repository=PUBLIC_REPO),
|
||||
200, 200, 200,200),
|
||||
TestSpec(url_for('get_repo_api', repository=ORG_REPO),
|
||||
TestSpec(url_for('get_repo', repository=ORG_REPO),
|
||||
403, 403, 200, 200),
|
||||
TestSpec(url_for('get_repo_api', repository=PRIVATE_REPO),
|
||||
TestSpec(url_for('get_repo', repository=PRIVATE_REPO),
|
||||
403, 403, 200, 200),
|
||||
|
||||
TestSpec(url_for('get_repo_builds', repository=PUBLIC_REPO),
|
||||
|
@ -403,20 +403,20 @@ def build_specs():
|
|||
TestSpec(url_for('delete_token', repository=PRIVATE_REPO,
|
||||
code=FAKE_TOKEN), admin_code=400).set_method('DELETE'),
|
||||
|
||||
TestSpec(url_for('subscribe_api'), 401, 400, 400, 400).set_method('PUT'),
|
||||
TestSpec(url_for('update_user_subscription'), 401, 400, 400, 400).set_method('PUT'),
|
||||
|
||||
TestSpec(url_for('subscribe_org_api', orgname=ORG),
|
||||
TestSpec(url_for('update_org_subscription', orgname=ORG),
|
||||
401, 403, 403, 400).set_method('PUT'),
|
||||
|
||||
TestSpec(url_for('get_subscription'), 401, 200, 200, 200),
|
||||
TestSpec(url_for('get_user_subscription'), 401, 200, 200, 200),
|
||||
|
||||
TestSpec(url_for('get_org_subscription', orgname=ORG)),
|
||||
|
||||
TestSpec(url_for('repo_logs_api', repository=PUBLIC_REPO), admin_code=403),
|
||||
TestSpec(url_for('repo_logs_api', repository=ORG_REPO)),
|
||||
TestSpec(url_for('repo_logs_api', repository=PRIVATE_REPO)),
|
||||
TestSpec(url_for('list_repo_logs', repository=PUBLIC_REPO), admin_code=403),
|
||||
TestSpec(url_for('list_repo_logs', repository=ORG_REPO)),
|
||||
TestSpec(url_for('list_repo_logs', repository=PRIVATE_REPO)),
|
||||
|
||||
TestSpec(url_for('org_logs_api', orgname=ORG)),
|
||||
TestSpec(url_for('list_org_logs', orgname=ORG)),
|
||||
]
|
||||
|
||||
|
||||
|
|
20
tools/backfill_commands.py
Normal 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
|
@ -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()
|
|
@ -1,19 +1,36 @@
|
|||
from app import stripe
|
||||
from collections import defaultdict
|
||||
|
||||
EXCLUDE_CID = {'cus_2iVlmwz8CpHgOj'}
|
||||
|
||||
offset = 0
|
||||
total_monthly_revenue = 0
|
||||
|
||||
def empty_tuple():
|
||||
return (0, 0)
|
||||
|
||||
plan_revenue = defaultdict(empty_tuple)
|
||||
|
||||
batch = stripe.Customer.all(count=100, offset=offset)
|
||||
while batch.data:
|
||||
for cust in batch.data:
|
||||
if cust.id not in EXCLUDE_CID and cust.subscription:
|
||||
sub = cust.subscription
|
||||
total_monthly_revenue += sub.plan.amount * sub.quantity
|
||||
subscribers, revenue = plan_revenue[sub.plan.id]
|
||||
plan_revenue[sub.plan.id] = (subscribers + 1,
|
||||
revenue + sub.plan.amount * sub.quantity)
|
||||
offset += len(batch.data)
|
||||
batch = stripe.Customer.all(count=100, offset=offset)
|
||||
|
||||
dollars = total_monthly_revenue / 100
|
||||
cents = total_monthly_revenue % 100
|
||||
print 'Monthly revenue: $%d.%02d' % (dollars, cents)
|
||||
def format_money(total_cents):
|
||||
dollars = total_cents / 100
|
||||
cents = total_cents % 100
|
||||
return dollars, cents
|
||||
|
||||
for plan_id, (subs, rev) in plan_revenue.items():
|
||||
d, c = format_money(rev)
|
||||
print '%s: $%d.%02d(%s)' % (plan_id, d, c, subs)
|
||||
|
||||
d, c = format_money(total_monthly_revenue)
|
||||
print 'Monthly revenue: $%d.%02d' % (d, c)
|
||||
|
|
|
@ -10,3 +10,12 @@ def cache_control(max_age=55):
|
|||
return response
|
||||
return add_max_age
|
||||
return wrap
|
||||
|
||||
|
||||
def no_cache(f):
|
||||
@wraps(f)
|
||||
def add_no_cache(*args, **kwargs):
|
||||
response = f(*args, **kwargs)
|
||||
response.headers['Cache-Control'] = 'no-cache'
|
||||
return response
|
||||
return add_no_cache
|
||||
|
|
|
@ -26,7 +26,8 @@ formatter = logging.Formatter(FORMAT)
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BUILD_SERVER_CMD = ('docker run -d -lxc-conf="lxc.aa_profile=unconfined" ' +
|
||||
BUILD_SERVER_CMD = ('docker run -d -p 5002:5002 ' +
|
||||
'-lxc-conf="lxc.aa_profile=unconfined" ' +
|
||||
'-privileged -e \'RESOURCE_URL=%s\' -e \'TAG=%s\' ' +
|
||||
'-e \'TOKEN=%s\' quay.io/quay/buildserver')
|
||||
|
||||
|
@ -85,7 +86,7 @@ def babysit_builder(request):
|
|||
api_key=do_api_key,
|
||||
name=name,
|
||||
region_id=regions.pop(),
|
||||
image_id=1004145, # Docker on 13.04
|
||||
image_id=app.config['DO_DOCKER_IMAGE'],
|
||||
size_id=66, # 512MB,
|
||||
backup_active=False)
|
||||
retry_command(droplet.create, [],
|
||||
|
@ -189,7 +190,7 @@ def babysit_builder(request):
|
|||
retry_command(droplet.destroy)
|
||||
|
||||
repository_build.status_url = None
|
||||
repository_build.build_node_id = None;
|
||||
repository_build.build_node_id = None
|
||||
repository_build.save()
|
||||
|
||||
return True
|
||||
|
|