Merge pull request #2489 from coreos-inc/apps-ui
Add basic user interface for application repos
This commit is contained in:
commit
a6954f246c
47 changed files with 1009 additions and 106 deletions
|
@ -20,7 +20,7 @@ CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY',
|
||||||
'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT',
|
'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT',
|
||||||
'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION',
|
'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION',
|
||||||
'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID',
|
'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID',
|
||||||
'STATIC_SITE_BUCKET', 'RECAPTCHA_SITE_KEY']
|
'STATIC_SITE_BUCKET', 'RECAPTCHA_SITE_KEY', 'CHANNEL_COLORS']
|
||||||
|
|
||||||
|
|
||||||
def frontend_visible_config(config_dict):
|
def frontend_visible_config(config_dict):
|
||||||
|
@ -437,3 +437,10 @@ class DefaultConfig(object):
|
||||||
FEATURE_TEAM_SYNCING = False
|
FEATURE_TEAM_SYNCING = False
|
||||||
TEAM_RESYNC_STALE_TIME = '30m'
|
TEAM_RESYNC_STALE_TIME = '30m'
|
||||||
TEAM_SYNC_WORKER_FREQUENCY = 60 # seconds
|
TEAM_SYNC_WORKER_FREQUENCY = 60 # seconds
|
||||||
|
|
||||||
|
# Colors for channels.
|
||||||
|
CHANNEL_COLORS = ['#969696', '#aec7e8', '#ff7f0e', '#ffbb78', '#2ca02c', '#98df8a', '#d62728',
|
||||||
|
'#ff9896', '#9467bd', '#c5b0d5', '#8c564b', '#c49c94', '#e377c2', '#f7b6d2',
|
||||||
|
'#7f7f7f', '#c7c7c7', '#bcbd22', '#1f77b4', '#17becf', '#9edae5', '#393b79',
|
||||||
|
'#5254a3', '#6b6ecf', '#9c9ede', '#9ecae1', '#31a354', '#b5cf6b', '#a1d99b',
|
||||||
|
'#8c6d31', '#ad494a', '#e7ba52', '#a55194']
|
||||||
|
|
|
@ -387,7 +387,7 @@ class PreOCIModel(DockerRegistryV2DataInterface):
|
||||||
return [_tag_view(tag) for tag in tags_query]
|
return [_tag_view(tag) for tag in tags_query]
|
||||||
|
|
||||||
def get_visible_repositories(self, username, limit, offset):
|
def get_visible_repositories(self, username, limit, offset):
|
||||||
query = model.repository.get_visible_repositories(username, repo_kind='image',
|
query = model.repository.get_visible_repositories(username, kind_filter='image',
|
||||||
include_public=(username is None))
|
include_public=(username is None))
|
||||||
query = query.limit(limit).offset(offset)
|
query = query.limit(limit).offset(offset)
|
||||||
return [_repository_for_repo(repo) for repo in query]
|
return [_repository_for_repo(repo) for repo in query]
|
||||||
|
|
|
@ -42,7 +42,10 @@ def filter_to_repos_for_user(query, username=None, namespace=None, repo_kind='im
|
||||||
return Repository.select().where(Repository.id == '-1')
|
return Repository.select().where(Repository.id == '-1')
|
||||||
|
|
||||||
# Filter on the type of repository.
|
# Filter on the type of repository.
|
||||||
query = query.where(Repository.kind == Repository.kind.get_id(repo_kind))
|
try:
|
||||||
|
query = query.where(Repository.kind == Repository.kind.get_id(repo_kind))
|
||||||
|
except RepositoryKind.DoesNotExist:
|
||||||
|
raise DataModelException('Unknown repository kind')
|
||||||
|
|
||||||
# Add the start ID if necessary.
|
# Add the start ID if necessary.
|
||||||
if start_id is not None:
|
if start_id is not None:
|
||||||
|
|
|
@ -276,8 +276,13 @@ def unstar_repository(user, repository):
|
||||||
raise DataModelException('Star not found.')
|
raise DataModelException('Star not found.')
|
||||||
|
|
||||||
|
|
||||||
def get_user_starred_repositories(user, repo_kind='image'):
|
def get_user_starred_repositories(user, kind_filter='image'):
|
||||||
""" Retrieves all of the repositories a user has starred. """
|
""" Retrieves all of the repositories a user has starred. """
|
||||||
|
try:
|
||||||
|
repo_kind = Repository.kind.get_id(kind_filter)
|
||||||
|
except RepositoryKind.DoesNotExist:
|
||||||
|
raise DataModelException('Unknown kind of repository')
|
||||||
|
|
||||||
query = (Repository
|
query = (Repository
|
||||||
.select(Repository, User, Visibility, Repository.id.alias('rid'))
|
.select(Repository, User, Visibility, Repository.id.alias('rid'))
|
||||||
.join(Star)
|
.join(Star)
|
||||||
|
@ -285,8 +290,7 @@ def get_user_starred_repositories(user, repo_kind='image'):
|
||||||
.join(User)
|
.join(User)
|
||||||
.switch(Repository)
|
.switch(Repository)
|
||||||
.join(Visibility)
|
.join(Visibility)
|
||||||
.where(Star.user == user,
|
.where(Star.user == user, Repository.kind == repo_kind))
|
||||||
Repository.kind == Repository.kind.get_id(repo_kind)))
|
|
||||||
|
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
@ -320,7 +324,7 @@ def get_when_last_modified(repository_ids):
|
||||||
return last_modified_map
|
return last_modified_map
|
||||||
|
|
||||||
|
|
||||||
def get_visible_repositories(username, namespace=None, repo_kind='image', include_public=False,
|
def get_visible_repositories(username, namespace=None, kind_filter='image', include_public=False,
|
||||||
start_id=None, limit=None):
|
start_id=None, limit=None):
|
||||||
""" Returns the repositories visible to the given user (if any).
|
""" Returns the repositories visible to the given user (if any).
|
||||||
"""
|
"""
|
||||||
|
@ -340,8 +344,8 @@ def get_visible_repositories(username, namespace=None, repo_kind='image', includ
|
||||||
# Note: We only need the permissions table if we will filter based on a user's permissions.
|
# Note: We only need the permissions table if we will filter based on a user's permissions.
|
||||||
query = query.switch(Repository).distinct().join(RepositoryPermission, JOIN_LEFT_OUTER)
|
query = query.switch(Repository).distinct().join(RepositoryPermission, JOIN_LEFT_OUTER)
|
||||||
|
|
||||||
query = _basequery.filter_to_repos_for_user(query, username, namespace, repo_kind, include_public,
|
query = _basequery.filter_to_repos_for_user(query, username, namespace, kind_filter,
|
||||||
start_id=start_id)
|
include_public, start_id=start_id)
|
||||||
|
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
query = query.limit(limit).order_by(SQL('rid'))
|
query = query.limit(limit).order_by(SQL('rid'))
|
||||||
|
|
|
@ -121,13 +121,17 @@ def create_app_release(repo, tag_name, manifest_data, digest, force=False):
|
||||||
ManifestBlob.create(manifest=manifest, blob=blob)
|
ManifestBlob.create(manifest=manifest, blob=blob)
|
||||||
return tag
|
return tag
|
||||||
|
|
||||||
|
def get_release_objs(repo, media_type=None):
|
||||||
def get_releases(repo, media_type=None):
|
""" Returns an array of Tag for a repo, with optional filtering by media_type. """
|
||||||
""" Returns an array of Tag.name for a repo, can filter by media_type. """
|
|
||||||
release_query = (Tag
|
release_query = (Tag
|
||||||
.select()
|
.select()
|
||||||
.where(Tag.repository == repo,
|
.where(Tag.repository == repo,
|
||||||
Tag.tag_kind == Tag.tag_kind.get_id("release")))
|
Tag.tag_kind == Tag.tag_kind.get_id("release")))
|
||||||
if media_type:
|
if media_type:
|
||||||
release_query = tag_model.filter_tags_by_media_type(release_query, media_type)
|
release_query = tag_model.filter_tags_by_media_type(release_query, media_type)
|
||||||
return [t.name for t in tag_model.tag_alive_oci(release_query)]
|
|
||||||
|
return tag_model.tag_alive_oci(release_query)
|
||||||
|
|
||||||
|
def get_releases(repo, media_type=None):
|
||||||
|
""" Returns an array of Tag.name for a repo, can filter by media_type. """
|
||||||
|
return [t.name for t in get_release_objs(repo, media_type)]
|
||||||
|
|
|
@ -4,12 +4,13 @@ import logging
|
||||||
import datetime
|
import datetime
|
||||||
import features
|
import features
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import timedelta, datetime
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
from flask import request, abort
|
from flask import request, abort
|
||||||
|
|
||||||
from app import dockerfile_build_queue
|
from app import dockerfile_build_queue
|
||||||
from data import model
|
from data import model, oci_model
|
||||||
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
|
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
|
||||||
require_repo_read, require_repo_write, require_repo_admin,
|
require_repo_read, require_repo_write, require_repo_admin,
|
||||||
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
|
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
|
||||||
|
@ -77,10 +78,10 @@ class RepositoryList(ApiResource):
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'description': 'Markdown encoded description for the repository',
|
'description': 'Markdown encoded description for the repository',
|
||||||
},
|
},
|
||||||
'kind': {
|
'repo_kind': {
|
||||||
'type': 'string',
|
'type': ['string', 'null'],
|
||||||
'description': 'The kind of repository',
|
'description': 'The kind of repository',
|
||||||
'enum': ['image', 'application'],
|
'enum': ['image', 'application', None],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -116,7 +117,7 @@ class RepositoryList(ApiResource):
|
||||||
if not REPOSITORY_NAME_REGEX.match(repository_name):
|
if not REPOSITORY_NAME_REGEX.match(repository_name):
|
||||||
raise InvalidRequest('Invalid repository name')
|
raise InvalidRequest('Invalid repository name')
|
||||||
|
|
||||||
kind = req.get('kind', 'image')
|
kind = req.get('repo_kind', 'image') or 'image'
|
||||||
repo = model.repository.create_repository(namespace_name, repository_name, owner, visibility,
|
repo = model.repository.create_repository(namespace_name, repository_name, owner, visibility,
|
||||||
repo_kind=kind)
|
repo_kind=kind)
|
||||||
repo.description = req['description']
|
repo.description = req['description']
|
||||||
|
@ -126,7 +127,8 @@ class RepositoryList(ApiResource):
|
||||||
'namespace': namespace_name}, repo=repo)
|
'namespace': namespace_name}, repo=repo)
|
||||||
return {
|
return {
|
||||||
'namespace': namespace_name,
|
'namespace': namespace_name,
|
||||||
'name': repository_name
|
'name': repository_name,
|
||||||
|
'kind': kind,
|
||||||
}, 201
|
}, 201
|
||||||
|
|
||||||
raise Unauthorized()
|
raise Unauthorized()
|
||||||
|
@ -144,6 +146,7 @@ class RepositoryList(ApiResource):
|
||||||
type=truthy_bool, default=False)
|
type=truthy_bool, default=False)
|
||||||
@query_param('popularity', 'Whether to include the repository\'s popularity metric.',
|
@query_param('popularity', 'Whether to include the repository\'s popularity metric.',
|
||||||
type=truthy_bool, default=False)
|
type=truthy_bool, default=False)
|
||||||
|
@query_param('repo_kind', 'The kind of repositories to return', type=str, default='image')
|
||||||
@page_support()
|
@page_support()
|
||||||
def get(self, page_token, parsed_args):
|
def get(self, page_token, parsed_args):
|
||||||
""" Fetch the list of repositories visible to the current user under a variety of situations.
|
""" Fetch the list of repositories visible to the current user under a variety of situations.
|
||||||
|
@ -158,6 +161,7 @@ class RepositoryList(ApiResource):
|
||||||
username = user.username if user else None
|
username = user.username if user else None
|
||||||
next_page_token = None
|
next_page_token = None
|
||||||
repos = None
|
repos = None
|
||||||
|
repo_kind = parsed_args['repo_kind']
|
||||||
|
|
||||||
# Lookup the requested repositories (either starred or non-starred.)
|
# Lookup the requested repositories (either starred or non-starred.)
|
||||||
if parsed_args['starred']:
|
if parsed_args['starred']:
|
||||||
|
@ -169,14 +173,15 @@ class RepositoryList(ApiResource):
|
||||||
def can_view_repo(repo):
|
def can_view_repo(repo):
|
||||||
return ReadRepositoryPermission(repo.namespace_user.username, repo.name).can()
|
return ReadRepositoryPermission(repo.namespace_user.username, repo.name).can()
|
||||||
|
|
||||||
unfiltered_repos = model.repository.get_user_starred_repositories(user)
|
unfiltered_repos = model.repository.get_user_starred_repositories(user, kind_filter=repo_kind)
|
||||||
repos = [repo for repo in unfiltered_repos if can_view_repo(repo)]
|
repos = [repo for repo in unfiltered_repos if can_view_repo(repo)]
|
||||||
elif parsed_args['namespace']:
|
elif parsed_args['namespace']:
|
||||||
# Repositories filtered by namespace do not need pagination (their results are fairly small),
|
# Repositories filtered by namespace do not need pagination (their results are fairly small),
|
||||||
# so we just do the lookup directly.
|
# so we just do the lookup directly.
|
||||||
repos = list(model.repository.get_visible_repositories(username=username,
|
repos = list(model.repository.get_visible_repositories(username=username,
|
||||||
include_public=parsed_args['public'],
|
include_public=parsed_args['public'],
|
||||||
namespace=parsed_args['namespace']))
|
namespace=parsed_args['namespace'],
|
||||||
|
kind_filter=repo_kind))
|
||||||
else:
|
else:
|
||||||
# Determine the starting offset for pagination. Note that we don't use the normal
|
# Determine the starting offset for pagination. Note that we don't use the normal
|
||||||
# model.modelutil.paginate method here, as that does not operate over UNION queries, which
|
# model.modelutil.paginate method here, as that does not operate over UNION queries, which
|
||||||
|
@ -188,7 +193,8 @@ class RepositoryList(ApiResource):
|
||||||
repo_query = model.repository.get_visible_repositories(username=username,
|
repo_query = model.repository.get_visible_repositories(username=username,
|
||||||
include_public=parsed_args['public'],
|
include_public=parsed_args['public'],
|
||||||
start_id=start_id,
|
start_id=start_id,
|
||||||
limit=REPOS_PER_PAGE+1)
|
limit=REPOS_PER_PAGE+1,
|
||||||
|
kind_filter=repo_kind)
|
||||||
|
|
||||||
repos, next_page_token = model.modelutil.paginate_query(repo_query, limit=REPOS_PER_PAGE,
|
repos, next_page_token = model.modelutil.paginate_query(repo_query, limit=REPOS_PER_PAGE,
|
||||||
id_alias='rid')
|
id_alias='rid')
|
||||||
|
@ -217,6 +223,7 @@ class RepositoryList(ApiResource):
|
||||||
'name': repo_obj.name,
|
'name': repo_obj.name,
|
||||||
'description': repo_obj.description,
|
'description': repo_obj.description,
|
||||||
'is_public': repo_obj.visibility_id == model.repository.get_public_repo_visibility().id,
|
'is_public': repo_obj.visibility_id == model.repository.get_public_repo_visibility().id,
|
||||||
|
'kind': repo_kind,
|
||||||
}
|
}
|
||||||
|
|
||||||
repo_id = repo_obj.rid
|
repo_id = repo_obj.rid
|
||||||
|
@ -266,6 +273,55 @@ class Repository(RepositoryParamResource):
|
||||||
"""Fetch the specified repository."""
|
"""Fetch the specified repository."""
|
||||||
logger.debug('Get repo: %s/%s' % (namespace, repository))
|
logger.debug('Get repo: %s/%s' % (namespace, repository))
|
||||||
|
|
||||||
|
repo = model.repository.get_repository(namespace, repository)
|
||||||
|
if repo is None:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
can_write = ModifyRepositoryPermission(namespace, repository).can()
|
||||||
|
can_admin = AdministerRepositoryPermission(namespace, repository).can()
|
||||||
|
|
||||||
|
is_starred = (model.repository.repository_is_starred(get_authenticated_user(), repo)
|
||||||
|
if get_authenticated_user() else False)
|
||||||
|
is_public = model.repository.is_repository_public(repo)
|
||||||
|
|
||||||
|
# Note: This is *temporary* code for the new OCI model stuff.
|
||||||
|
if repo.kind.name == 'application':
|
||||||
|
def channel_view(channel):
|
||||||
|
return {
|
||||||
|
'name': channel.name,
|
||||||
|
'release': channel.linked_tag.name,
|
||||||
|
'last_modified': format_date(datetime.fromtimestamp(channel.linked_tag.lifetime_start / 1000)),
|
||||||
|
}
|
||||||
|
|
||||||
|
def release_view(release):
|
||||||
|
return {
|
||||||
|
'name': release.name,
|
||||||
|
'last_modified': format_date(datetime.fromtimestamp(release.lifetime_start / 1000)),
|
||||||
|
'channels': releases_channels_map[release.name],
|
||||||
|
}
|
||||||
|
|
||||||
|
channels = oci_model.channel.get_repo_channels(repo)
|
||||||
|
releases_channels_map = defaultdict(list)
|
||||||
|
for channel in channels:
|
||||||
|
releases_channels_map[channel.linked_tag.name].append(channel.name)
|
||||||
|
|
||||||
|
repo_data = {
|
||||||
|
'namespace': namespace,
|
||||||
|
'name': repository,
|
||||||
|
'kind': repo.kind.name,
|
||||||
|
'description': repo.description,
|
||||||
|
'can_write': can_write,
|
||||||
|
'can_admin': can_admin,
|
||||||
|
'is_public': is_public,
|
||||||
|
'is_organization': repo.namespace_user.organization,
|
||||||
|
'is_starred': is_starred,
|
||||||
|
'channels': [channel_view(chan) for chan in channels],
|
||||||
|
'releases': [release_view(release) for release in oci_model.release.get_release_objs(repo)],
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo_data
|
||||||
|
|
||||||
|
# Older image-only repo code.
|
||||||
def tag_view(tag, manifest):
|
def tag_view(tag, manifest):
|
||||||
tag_info = {
|
tag_info = {
|
||||||
'name': tag.name,
|
'name': tag.name,
|
||||||
|
@ -282,63 +338,54 @@ class Repository(RepositoryParamResource):
|
||||||
|
|
||||||
return tag_info
|
return tag_info
|
||||||
|
|
||||||
repo = model.repository.get_repository(namespace, repository)
|
|
||||||
stats = None
|
stats = None
|
||||||
if repo:
|
tags = model.tag.list_repository_tags(namespace, repository, include_storage=True)
|
||||||
tags = model.tag.list_repository_tags(namespace, repository, include_storage=True)
|
manifests = model.tag.get_tag_manifests(tags)
|
||||||
manifests = model.tag.get_tag_manifests(tags)
|
|
||||||
|
|
||||||
tag_dict = {tag.name: tag_view(tag, manifests.get(tag.id)) for tag in tags}
|
tag_dict = {tag.name: tag_view(tag, manifests.get(tag.id)) for tag in tags}
|
||||||
can_write = ModifyRepositoryPermission(namespace, repository).can()
|
if parsed_args['includeStats']:
|
||||||
can_admin = AdministerRepositoryPermission(namespace, repository).can()
|
stats = []
|
||||||
|
found_dates = {}
|
||||||
|
|
||||||
is_starred = (model.repository.repository_is_starred(get_authenticated_user(), repo)
|
start_date = datetime.now() - timedelta(days=MAX_DAYS_IN_3_MONTHS)
|
||||||
if get_authenticated_user() else False)
|
counts = model.log.get_repository_action_counts(repo, start_date)
|
||||||
is_public = model.repository.is_repository_public(repo)
|
for count in counts:
|
||||||
|
stats.append({
|
||||||
|
'date': count.date.isoformat(),
|
||||||
|
'count': count.count,
|
||||||
|
})
|
||||||
|
|
||||||
if parsed_args['includeStats']:
|
found_dates['%s/%s' % (count.date.month, count.date.day)] = True
|
||||||
stats = []
|
|
||||||
found_dates = {}
|
|
||||||
|
|
||||||
start_date = datetime.now() - timedelta(days=MAX_DAYS_IN_3_MONTHS)
|
# Fill in any missing stats with zeros.
|
||||||
counts = model.log.get_repository_action_counts(repo, start_date)
|
for day in range(1, MAX_DAYS_IN_3_MONTHS):
|
||||||
for count in counts:
|
day_date = datetime.now() - timedelta(days=day)
|
||||||
|
key = '%s/%s' % (day_date.month, day_date.day)
|
||||||
|
if not key in found_dates:
|
||||||
stats.append({
|
stats.append({
|
||||||
'date': count.date.isoformat(),
|
'date': day_date.date().isoformat(),
|
||||||
'count': count.count,
|
'count': 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
found_dates['%s/%s' % (count.date.month, count.date.day)] = True
|
repo_data = {
|
||||||
|
'namespace': namespace,
|
||||||
|
'name': repository,
|
||||||
|
'kind': repo.kind.name,
|
||||||
|
'description': repo.description,
|
||||||
|
'tags': tag_dict,
|
||||||
|
'can_write': can_write,
|
||||||
|
'can_admin': can_admin,
|
||||||
|
'is_public': is_public,
|
||||||
|
'is_organization': repo.namespace_user.organization,
|
||||||
|
'is_starred': is_starred,
|
||||||
|
'status_token': repo.badge_token if not is_public else '',
|
||||||
|
}
|
||||||
|
|
||||||
# Fill in any missing stats with zeros.
|
if stats is not None:
|
||||||
for day in range(1, MAX_DAYS_IN_3_MONTHS):
|
repo_data['stats'] = stats
|
||||||
day_date = datetime.now() - timedelta(days=day)
|
|
||||||
key = '%s/%s' % (day_date.month, day_date.day)
|
|
||||||
if not key in found_dates:
|
|
||||||
stats.append({
|
|
||||||
'date': day_date.date().isoformat(),
|
|
||||||
'count': 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
repo_data = {
|
return repo_data
|
||||||
'namespace': namespace,
|
|
||||||
'name': repository,
|
|
||||||
'description': repo.description,
|
|
||||||
'tags': tag_dict,
|
|
||||||
'can_write': can_write,
|
|
||||||
'can_admin': can_admin,
|
|
||||||
'is_public': is_public,
|
|
||||||
'is_organization': repo.namespace_user.organization,
|
|
||||||
'is_starred': is_starred,
|
|
||||||
'status_token': repo.badge_token if not is_public else '',
|
|
||||||
}
|
|
||||||
|
|
||||||
if stats is not None:
|
|
||||||
repo_data['stats'] = stats
|
|
||||||
|
|
||||||
return repo_data
|
|
||||||
|
|
||||||
raise NotFound()
|
|
||||||
|
|
||||||
@require_repo_write
|
@require_repo_write
|
||||||
@nickname('updateRepo')
|
@nickname('updateRepo')
|
||||||
|
@ -361,7 +408,6 @@ class Repository(RepositoryParamResource):
|
||||||
|
|
||||||
@require_repo_admin
|
@require_repo_admin
|
||||||
@nickname('deleteRepository')
|
@nickname('deleteRepository')
|
||||||
@disallow_for_app_repositories
|
|
||||||
def delete(self, namespace, repository):
|
def delete(self, namespace, repository):
|
||||||
""" Delete a repository. """
|
""" Delete a repository. """
|
||||||
model.repository.purge_repository(namespace, repository)
|
model.repository.purge_repository(namespace, repository)
|
||||||
|
|
|
@ -28,7 +28,6 @@ TRIGGER_ARGS = {'trigger_uuid': '1234'}
|
||||||
FIELD_ARGS = {'trigger_uuid': '1234', 'field_name': 'foobar'}
|
FIELD_ARGS = {'trigger_uuid': '1234', 'field_name': 'foobar'}
|
||||||
|
|
||||||
@pytest.mark.parametrize('resource, method, params', [
|
@pytest.mark.parametrize('resource, method, params', [
|
||||||
(Repository, 'delete', None),
|
|
||||||
(RepositoryBuildList, 'get', None),
|
(RepositoryBuildList, 'get', None),
|
||||||
(RepositoryBuildList, 'post', None),
|
(RepositoryBuildList, 'post', None),
|
||||||
(RepositoryBuildResource, 'get', BUILD_ARGS),
|
(RepositoryBuildResource, 'get', BUILD_ARGS),
|
||||||
|
|
|
@ -191,6 +191,14 @@ def buildtrigger(path, trigger):
|
||||||
return index('')
|
return index('')
|
||||||
|
|
||||||
|
|
||||||
|
@route_show_if(features.APP_REGISTRY)
|
||||||
|
@web.route('/application/', defaults={'path': ''})
|
||||||
|
@web.route('/application/<path:path>', methods=['GET'])
|
||||||
|
@no_cache
|
||||||
|
def application(path):
|
||||||
|
return index('')
|
||||||
|
|
||||||
|
|
||||||
@web.route('/starred/')
|
@web.route('/starred/')
|
||||||
@no_cache
|
@no_cache
|
||||||
def starred():
|
def starred():
|
||||||
|
|
75
static/css/directives/ui/app-public-view.css
Normal file
75
static/css/directives/ui/app-public-view.css
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
.app-public-view-element {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .app-header {
|
||||||
|
padding: 10px;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .app-header i.fa {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .main-content {
|
||||||
|
min-height: 300px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .side-bar {
|
||||||
|
padding: 20px;
|
||||||
|
border-left: 1px solid #dddddd;
|
||||||
|
background-color: #fdfdfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .side-bar .sidebar-table {
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .side-bar .sidebar-table h4 {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .app-row {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .main-content,
|
||||||
|
.app-public-view-element .side-bar {
|
||||||
|
float: none;
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .side-bar .visibility-indicator-component-element {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .tab-content {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .tab-content .tab-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .co-main-content-panel {
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .co-top-tab-bar li {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .co-panel .co-panel-heading {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-left: -30px;
|
||||||
|
margin-right: -30px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-public-view-element .co-panel .co-panel-heading i.fa {
|
||||||
|
display: none;
|
||||||
|
}
|
54
static/css/directives/ui/channel-icon.css
Normal file
54
static/css/directives/ui/channel-icon.css
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
.channel-icon-element {
|
||||||
|
position: relative;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
height: 32px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* From: http://csshexagon.com/ */
|
||||||
|
.channel-icon-element .hexagon {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 30px;
|
||||||
|
height: 17.32px;
|
||||||
|
background-color: red;
|
||||||
|
margin: 8.66px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-icon-element .hexagon .before,
|
||||||
|
.channel-icon-element .hexagon .after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
border-left: 15px solid transparent;
|
||||||
|
border-right: 15px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-icon-element .hexagon .before {
|
||||||
|
bottom: 100%;
|
||||||
|
border-bottom: 8.66px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-icon-element .hexagon .after {
|
||||||
|
top: 100%;
|
||||||
|
width: 0;
|
||||||
|
border-top: 8.66px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-icon-element b {
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
left: 0px;
|
||||||
|
right: 0px;
|
||||||
|
bottom: 0px;
|
||||||
|
|
||||||
|
line-height: 34px;
|
||||||
|
text-align: center;
|
||||||
|
display: inline-block;
|
||||||
|
z-index: 1;
|
||||||
|
color: white;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
17
static/css/directives/ui/visibility-indicator-component.css
Normal file
17
static/css/directives/ui/visibility-indicator-component.css
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
.visibility-indicator-component-element {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px;
|
||||||
|
color: white;
|
||||||
|
height: 25px;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-indicator-component-element.private {
|
||||||
|
background-color: #d64456;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visibility-indicator-component-element.public {
|
||||||
|
background-color: #2fc98e;
|
||||||
|
}
|
|
@ -7,6 +7,19 @@
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.new-repo .repo-kind-select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-repo .repo-kind-select {
|
||||||
|
display: inline-block;
|
||||||
|
width: 200px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-repo .namespace-selector-header .slash {
|
.new-repo .namespace-selector-header .slash {
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
|
|
||||||
<!-- Signed in -->
|
<!-- Signed in -->
|
||||||
<ul class="nav navbar-nav navbar-links" ng-if="!user.anonymous">
|
<ul class="nav navbar-nav navbar-links" ng-if="!user.anonymous">
|
||||||
|
<li quay-require="['APP_REGISTRY']"><a ng-href="/application/" quay-section="application">Applications</a></li>
|
||||||
<li><a ng-href="/repository/" quay-section="repository">Repositories</a></li>
|
<li><a ng-href="/repository/" quay-section="repository">Repositories</a></li>
|
||||||
<li><a ng-href="/tutorial/" quay-section="tutorial">Tutorial</a></li>
|
<li><a ng-href="/tutorial/" quay-section="tutorial">Tutorial</a></li>
|
||||||
<li><a href="https://docs.quay.io/" ng-safenewtab>Docs</a></li>
|
<li><a href="https://docs.quay.io/" ng-safenewtab>Docs</a></li>
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
<i class="fa fa-lock fa-lg" style="{{ repo.is_public ? 'visibility: hidden' : 'visibility: inherit' }}" data-title="Private Repository"></i>
|
<i class="fa fa-lock fa-lg" style="{{ repo.is_public ? 'visibility: hidden' : 'visibility: inherit' }}" data-title="Private Repository"></i>
|
||||||
<i class="fa fa-hdd-o"></i>
|
<i class="fa fa-hdd-o" ng-if="repo.kind == 'image'"></i>
|
||||||
|
<i class="fa ci-appcube" ng-if="repo.kind == 'application'"></i>
|
||||||
|
|
|
@ -28,7 +28,11 @@
|
||||||
<div class="col-lg-10 col-md-10 col-sm-10 col-xs-10 repo-panel-title-row">
|
<div class="col-lg-10 col-md-10 col-sm-10 col-xs-10 repo-panel-title-row">
|
||||||
<span class="repo-icon repo-circle no-background" repo="repository"></span>
|
<span class="repo-icon repo-circle no-background" repo="repository"></span>
|
||||||
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}" class="repo-panel-repo-link"
|
<a ng-href="/repository/{{repository.namespace}}/{{ repository.name }}" class="repo-panel-repo-link"
|
||||||
data-repo="{{repository.namespace}}/{{ repository.name }}">
|
data-repo="{{repository.namespace}}/{{ repository.name }}" ng-if="repoKind != 'application'">
|
||||||
|
<span ng-show="!hideNamespaces">{{ repository.namespace }}/</span>{{ repository.name }}
|
||||||
|
</a>
|
||||||
|
<a ng-href="/application/{{repository.namespace}}/{{ repository.name }}" class="repo-panel-repo-link"
|
||||||
|
data-repo="{{repository.namespace}}/{{ repository.name }}" ng-if="repoKind == 'application'">
|
||||||
<span ng-show="!hideNamespaces">{{ repository.namespace }}/</span>{{ repository.name }}
|
<span ng-show="!hideNamespaces">{{ repository.namespace }}/</span>{{ repository.name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
<td class="hidden-xs"
|
<td class="hidden-xs"
|
||||||
ng-class="tablePredicateClass('is_starred', options.predicate, options.reverse)"
|
ng-class="tablePredicateClass('is_starred', options.predicate, options.reverse)"
|
||||||
style="width: 70px"
|
style="width: 70px"
|
||||||
ng-if="loggedIn">
|
ng-if="loggedIn && repoKind != 'application'">
|
||||||
<a ng-click="orderBy('is_starred')">Star</a>
|
<a ng-click="orderBy('is_starred')">Star</a>
|
||||||
</td>
|
</td>
|
||||||
</thead>
|
</thead>
|
||||||
|
@ -47,7 +47,11 @@
|
||||||
<tr ng-repeat="repository in orderedRepositories.entries | slice:(reposPerPage * options.page):(reposPerPage * (options.page + 1))">
|
<tr ng-repeat="repository in orderedRepositories.entries | slice:(reposPerPage * options.page):(reposPerPage * (options.page + 1))">
|
||||||
<td class="repo-name-icon">
|
<td class="repo-name-icon">
|
||||||
<span class="avatar" size="24" data="::getAvatarData(repository.namespace)"></span>
|
<span class="avatar" size="24" data="::getAvatarData(repository.namespace)"></span>
|
||||||
<a href="/repository/{{ ::repository.namespace }}/{{ ::repository.name }}">
|
<a href="/repository/{{ ::repository.namespace }}/{{ ::repository.name }}" ng-if="repoKind != 'application'">
|
||||||
|
<span class="namespace">{{ ::repository.namespace }}</span>
|
||||||
|
<span class="name">{{ ::repository.name }}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/application/{{ ::repository.namespace }}/{{ ::repository.name }}" ng-if="repoKind == 'application'">
|
||||||
<span class="namespace">{{ ::repository.namespace }}</span>
|
<span class="namespace">{{ ::repository.namespace }}</span>
|
||||||
<span class="name">{{ ::repository.name }}</span>
|
<span class="name">{{ ::repository.name }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
@ -62,7 +66,7 @@
|
||||||
<span class="strength-indicator" value="::repository.popularity" maximum="::maxPopularity"
|
<span class="strength-indicator" value="::repository.popularity" maximum="::maxPopularity"
|
||||||
log-base="10"></span>
|
log-base="10"></span>
|
||||||
</td>
|
</td>
|
||||||
<td ng-show="loggedIn">
|
<td ng-show="loggedIn && repoKind != 'application'">
|
||||||
<span class="repo-star" repository="::repository"
|
<span class="repo-star" repository="::repository"
|
||||||
star-toggled="starToggled({'repository': repository})"></span>
|
star-toggled="starToggled({'repository': repository})"></span>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -12,7 +12,8 @@
|
||||||
<!-- Table View -->
|
<!-- Table View -->
|
||||||
<div ng-if="showAsList">
|
<div ng-if="showAsList">
|
||||||
<div class="repo-list-table" repositories-resources="resources" namespaces="namespaces"
|
<div class="repo-list-table" repositories-resources="resources" namespaces="namespaces"
|
||||||
star-toggled="starToggled({'repository': repository})">
|
star-toggled="starToggled({'repository': repository})"
|
||||||
|
repo-kind="{{ repoKind }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -22,7 +23,8 @@
|
||||||
<div class="repo-list-grid" repositories-resource="starredRepositories"
|
<div class="repo-list-grid" repositories-resource="starredRepositories"
|
||||||
starred="true"
|
starred="true"
|
||||||
star-toggled="starToggled({'repository': repository})"
|
star-toggled="starToggled({'repository': repository})"
|
||||||
ng-if="starredRepositories">
|
ng-if="starredRepositories"
|
||||||
|
repo-kind="{{ repoKind }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User and Org Repository Listings -->
|
<!-- User and Org Repository Listings -->
|
||||||
|
@ -31,7 +33,8 @@
|
||||||
starred="false" namespace="namespace"
|
starred="false" namespace="namespace"
|
||||||
star-toggled="starToggled({'repository': repository})"
|
star-toggled="starToggled({'repository': repository})"
|
||||||
hide-title="namespaces.length == 1"
|
hide-title="namespaces.length == 1"
|
||||||
hide-namespaces="true">
|
hide-namespaces="true"
|
||||||
|
repo-kind="{{ repoKind }}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<div class="repo-panel-settings-element">
|
<div class="repo-panel-settings-element">
|
||||||
<h3 class="tab-header">Repository Settings</h3>
|
<h3 class="tab-header"><span class="repository-title" repository="repository"></span> Settings</h3>
|
||||||
|
|
||||||
<!-- User/Team Permissions -->
|
<!-- User/Team Permissions -->
|
||||||
<div class="co-panel" id="repoPermissions">
|
<div class="co-panel" id="repoPermissions">
|
||||||
|
@ -11,7 +11,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Access Tokens (DEPRECATED) -->
|
<!-- Access Tokens (DEPRECATED) -->
|
||||||
<div class="co-panel" ng-show="hasTokens">
|
<div class="co-panel" ng-show="hasTokens && repository.kind == 'image'">
|
||||||
<div class="co-panel-heading"><i class="fa fa-key"></i> Access Token Permissions</div>
|
<div class="co-panel-heading"><i class="fa fa-key"></i> Access Token Permissions</div>
|
||||||
<div class="panel-body" style="padding-top: 5px;">
|
<div class="panel-body" style="padding-top: 5px;">
|
||||||
<div class="repository-tokens-table" repository="repository" has-tokens="hasTokens" is-enabled="isEnabled"></div>
|
<div class="repository-tokens-table" repository="repository" has-tokens="hasTokens" is-enabled="isEnabled"></div>
|
||||||
|
@ -19,18 +19,22 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Events and Notifications -->
|
<!-- Events and Notifications -->
|
||||||
<div class="repository-events-table" repository="repository"
|
<div ng-if="repository.kind == 'image'">
|
||||||
is-enabled="isEnabled"></div>
|
<div class="repository-events-table" repository="repository"
|
||||||
|
is-enabled="isEnabled"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Visibility settings -->
|
<!-- Visibility settings -->
|
||||||
<div class="co-panel">
|
<div class="co-panel">
|
||||||
<div class="co-panel-heading"><i class="fa fa-unlock-alt"></i> Repository Visibility</div>
|
<div class="co-panel-heading"><i class="fa fa-unlock-alt"></i>
|
||||||
|
<span class="repository-title" repository="repository"></span> Visibility
|
||||||
|
</div>
|
||||||
<div class="cor-loader" ng-show="!repository"></div>
|
<div class="cor-loader" ng-show="!repository"></div>
|
||||||
<div ng-show="repository">
|
<div ng-show="repository">
|
||||||
<!-- Public/Private -->
|
<!-- Public/Private -->
|
||||||
<div class="panel-body panel-section lock-section" ng-if="!repository.is_public">
|
<div class="panel-body panel-section lock-section" ng-if="!repository.is_public">
|
||||||
<i class="fa fa-lock lock-icon"></i>
|
<i class="fa fa-lock lock-icon"></i>
|
||||||
<div>This repository is currently <b>private</b>. Only users on the permissions list may view and interact with it.</div>
|
<div>This <span class="repository-title" repository="repository"></span> is currently <b>private</b>. Only users on the permissions list may view and interact with it.</div>
|
||||||
|
|
||||||
<button class="btn btn-default" ng-click="askChangeAccess('public')">
|
<button class="btn btn-default" ng-click="askChangeAccess('public')">
|
||||||
<i class="fa fa-unlock"></i>Make Public
|
<i class="fa fa-unlock"></i>Make Public
|
||||||
|
@ -40,7 +44,7 @@
|
||||||
<div class="panel-body panel-section lock-section" ng-if="repository.is_public">
|
<div class="panel-body panel-section lock-section" ng-if="repository.is_public">
|
||||||
<i class="fa fa-unlock lock-icon"></i>
|
<i class="fa fa-unlock lock-icon"></i>
|
||||||
|
|
||||||
<div>This repository is currently <b>public</b> and is visible to all users, and may be pulled by all users.</div>
|
<div>This <span class="repository-title" repository="repository"></span> is currently <b>public</b> and is visible to all users, and may be pulled by all users.</div>
|
||||||
|
|
||||||
<button class="btn btn-default" ng-click="askChangeAccess('private')" ng-show="!planRequired">
|
<button class="btn btn-default" ng-click="askChangeAccess('private')" ng-show="!planRequired">
|
||||||
<i class="fa fa-lock"></i>Make Private
|
<i class="fa fa-lock"></i>Make Private
|
||||||
|
@ -56,17 +60,19 @@
|
||||||
|
|
||||||
<!-- Delete repository -->
|
<!-- Delete repository -->
|
||||||
<div class="co-panel">
|
<div class="co-panel">
|
||||||
<div class="co-panel-heading"><i class="fa fa-trash"></i> Delete Repository</div>
|
<div class="co-panel-heading">
|
||||||
|
<i class="fa fa-trash"></i> Delete <span class="repository-title" repository="repository"></span>
|
||||||
|
</div>
|
||||||
<div class="cor-loader" ng-show="!repository"></div>
|
<div class="cor-loader" ng-show="!repository"></div>
|
||||||
<div ng-show="repository">
|
<div ng-show="repository">
|
||||||
<div class="panel-body panel-section">
|
<div class="panel-body panel-section">
|
||||||
<div class="co-alert co-alert-danger">
|
<div class="co-alert co-alert-danger">
|
||||||
<button class="btn btn-danger delete-btn" ng-click="askDelete()">
|
<button class="btn btn-danger delete-btn" ng-click="askDelete()">
|
||||||
<i class="fa fa-trash"></i>
|
<i class="fa fa-trash"></i>
|
||||||
Delete Repository
|
Delete <span class="repository-title" repository="repository"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
Deleting a repository <b>cannot be undone</b>. Here be dragons!
|
Deleting a <span class="repository-title" repository="repository"></span> <b>cannot be undone</b>. Here be dragons!
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -74,7 +80,7 @@
|
||||||
|
|
||||||
|
|
||||||
<!-- Build Status Badge -->
|
<!-- Build Status Badge -->
|
||||||
<div class="co-panel hidden-xs">
|
<div class="co-panel hidden-xs" ng-if="repository.kind == 'image'">
|
||||||
<div class="co-panel-heading"><i class="fa fa-tasks"></i> Build Status Badge</div>
|
<div class="co-panel-heading"><i class="fa fa-tasks"></i> Build Status Badge</div>
|
||||||
<div class="cor-loader" ng-show="!repository"></div>
|
<div class="cor-loader" ng-show="!repository"></div>
|
||||||
<div ng-show="repository">
|
<div ng-show="repository">
|
||||||
|
@ -123,12 +129,12 @@
|
||||||
<div class="cor-confirm-dialog"
|
<div class="cor-confirm-dialog"
|
||||||
dialog-context="deleteRepoInfo"
|
dialog-context="deleteRepoInfo"
|
||||||
dialog-action="deleteRepo(info, callback)"
|
dialog-action="deleteRepo(info, callback)"
|
||||||
dialog-title="Delete Repository"
|
dialog-title="Delete"
|
||||||
dialog-action-title="Delete Repository">
|
dialog-action-title="Delete">
|
||||||
<div class="co-alert co-alert-danger" style="margin-bottom: 10px;">
|
<div class="co-alert co-alert-danger" style="margin-bottom: 10px;">
|
||||||
This action cannot be undone!
|
This action cannot be undone!
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Continue with deletion of this repository?
|
Continue with deletion of this <span class="repository-title" repository="repository"></span>?
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
4
static/directives/repository-title.html
Normal file
4
static/directives/repository-title.html
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<span class="repository-title-element">
|
||||||
|
<span ng-if="repository.kind == 'image'">Repository</span>
|
||||||
|
<span ng-if="repository.kind == 'application'">Application</span>
|
||||||
|
</span>
|
|
@ -15,6 +15,10 @@ angular.module('quay').directive('repoPanelSettings', function () {
|
||||||
controller: function($scope, $element, ApiService, Config) {
|
controller: function($scope, $element, ApiService, Config) {
|
||||||
$scope.deleteDialogCounter = 0;
|
$scope.deleteDialogCounter = 0;
|
||||||
|
|
||||||
|
var getTitle = function(repo) {
|
||||||
|
return repo.kind == 'application' ? 'application' : 'image';
|
||||||
|
};
|
||||||
|
|
||||||
$scope.getBadgeFormat = function(format, repository) {
|
$scope.getBadgeFormat = function(format, repository) {
|
||||||
if (!repository) { return ''; }
|
if (!repository) { return ''; }
|
||||||
|
|
||||||
|
@ -52,7 +56,8 @@ angular.module('quay').directive('repoPanelSettings', function () {
|
||||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
'repository': $scope.repository.namespace + '/' + $scope.repository.name
|
||||||
};
|
};
|
||||||
|
|
||||||
var errorHandler = ApiService.errorDisplay('Could not delete repository', callback);
|
var errorHandler = ApiService.errorDisplay(
|
||||||
|
'Could not delete ' + getTitle($scope.repository), callback);
|
||||||
|
|
||||||
ApiService.deleteRepository(null, params).then(function() {
|
ApiService.deleteRepository(null, params).then(function() {
|
||||||
callback(true);
|
callback(true);
|
||||||
|
@ -62,9 +67,11 @@ angular.module('quay').directive('repoPanelSettings', function () {
|
||||||
}, errorHandler);
|
}, errorHandler);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
$scope.askChangeAccess = function(newAccess) {
|
$scope.askChangeAccess = function(newAccess) {
|
||||||
bootbox.confirm('Are you sure you want to make this repository ' + newAccess + '?', function(r) {
|
var msg = 'Are you sure you want to make this ' + getTitle($scope.repository) + ' ' +
|
||||||
|
newAccess + '?';
|
||||||
|
|
||||||
|
bootbox.confirm(msg, function(r) {
|
||||||
if (!r) { return; }
|
if (!r) { return; }
|
||||||
$scope.changeAccess(newAccess);
|
$scope.changeAccess(newAccess);
|
||||||
});
|
});
|
||||||
|
@ -81,7 +88,7 @@ angular.module('quay').directive('repoPanelSettings', function () {
|
||||||
|
|
||||||
ApiService.changeRepoVisibility(visibility, params).then(function() {
|
ApiService.changeRepoVisibility(visibility, params).then(function() {
|
||||||
$scope.repository.is_public = newAccess == 'public';
|
$scope.repository.is_public = newAccess == 'public';
|
||||||
}, ApiService.errorDisplay('Could not change repository visibility'));
|
}, ApiService.errorDisplay('Could not change visibility'));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
<div class="app-public-view-element">
|
||||||
|
<div class="co-main-content-panel">
|
||||||
|
<div class="app-row">
|
||||||
|
<!-- Main panel -->
|
||||||
|
<div class="col-md-9 main-content">
|
||||||
|
<!-- App Header -->
|
||||||
|
<div class="app-header">
|
||||||
|
<a href="https://coreos.com/blog/quay-application-registry-for-kubernetes.html" class="hidden-xs hidden-sm" style="float: right; padding: 6px;" ng-safenewtab><i class="fa fa-info-circle" style="margin-right: 6px;"></i>Learn more about applications</a>
|
||||||
|
<h3><i class="fa ci-appcube"></i>{{ $ctrl.repository.namespace }}/{{ $ctrl.repository.name }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<ul class="co-top-tab-bar">
|
||||||
|
<li class="co-top-tab" ng-class="$ctrl.currentTab == 'description' ? 'active': ''" ng-click="$ctrl.showTab('description')">
|
||||||
|
Description
|
||||||
|
</li>
|
||||||
|
<li class="co-top-tab" ng-class="$ctrl.currentTab == 'channels' ? 'active': ''" ng-click="$ctrl.showTab('channels')">
|
||||||
|
Channels
|
||||||
|
</li>
|
||||||
|
<li class="co-top-tab" ng-class="$ctrl.currentTab == 'releases' ? 'active': ''" ng-click="$ctrl.showTab('releases')">
|
||||||
|
Releases
|
||||||
|
</li>
|
||||||
|
<li class="co-top-tab" ng-class="$ctrl.currentTab == 'settings' ? 'active': ''" ng-click="$ctrl.showTab('settings')"
|
||||||
|
ng-if="$ctrl.repository.can_admin">
|
||||||
|
Settings
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<div ng-show="$ctrl.currentTab == 'description'">
|
||||||
|
<div class="description markdown-input"
|
||||||
|
content="$ctrl.repository.description"
|
||||||
|
can-write="$ctrl.repository.can_write"
|
||||||
|
content-changed="$ctrl.updateDescription"
|
||||||
|
field-title="'application description'">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ng-show="$ctrl.currentTab == 'channels'">
|
||||||
|
<div ng-show="!$ctrl.repository.channels.length && $ctrl.repository.can_write">
|
||||||
|
<h3>No channels found for this application</h3>
|
||||||
|
<br>
|
||||||
|
<p>
|
||||||
|
To push a new channel (from within the Helm package directory and with the <a href="https://coreos.com/apps" ng-safenewtab>Helm registry plugin</a> installed):
|
||||||
|
<pre class="command">
|
||||||
|
helm registry push --namespace {{ $ctrl.repository.namespace }} --channel {channelName} {{ $ctrl.Config.SERVER_HOSTNAME }}
|
||||||
|
</pre>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ng-show="$ctrl.repository.channels.length || !$ctrl.repository.can_write">
|
||||||
|
<cor-table table-data="$ctrl.repository.channels" table-item-title="channels" filter-fields="['name']">
|
||||||
|
<cor-table-col datafield="name" sortfield="name" title="Name"
|
||||||
|
templateurl="/static/js/directives/ui/app-public-view/channel-name.html"></cor-table-col>
|
||||||
|
<cor-table-col datafield="release" sortfield="release" title="Current Release"></cor-table-col>
|
||||||
|
<cor-table-col datafield="last_modified" sortfield="last_modified" title="Last Modified"
|
||||||
|
selected="true" dataKind="datetime"
|
||||||
|
templateurl="/static/js/directives/ui/app-public-view/last-modified.html"></cor-table-col>
|
||||||
|
</cor-table>
|
||||||
|
</div>
|
||||||
|
</div> <!-- /channels -->
|
||||||
|
|
||||||
|
<div ng-show="$ctrl.currentTab == 'releases'">
|
||||||
|
<div ng-show="!$ctrl.repository.releases.length && $ctrl.repository.can_write">
|
||||||
|
<h3>No releases found for this application</h3>
|
||||||
|
<br>
|
||||||
|
<p>
|
||||||
|
To push a new release (from within the Helm package directory and with the <a href="https://coreos.com/apps" ng-safenewtab>Helm registry plugin</a> installed):
|
||||||
|
<pre class="command">
|
||||||
|
helm registry push --namespace {{ $ctrl.repository.namespace }} {{ $ctrl.Config.SERVER_HOSTNAME }}
|
||||||
|
</pre>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ng-show="$ctrl.repository.releases.length || !$ctrl.repository.can_write">
|
||||||
|
<cor-table table-data="$ctrl.repository.releases" table-item-title="releases" filter-fields="['name']">
|
||||||
|
<cor-table-col datafield="name" sortfield="name" title="Name"></cor-table-col>
|
||||||
|
<cor-table-col datafield="last_modified" sortfield="last_modified"
|
||||||
|
title="Created"
|
||||||
|
selected="true" dataKind="datetime"
|
||||||
|
templateurl="/static/js/directives/ui/app-public-view/last-modified.html"></cor-table-col>
|
||||||
|
<cor-table-col datafield="channels" title="Channels"
|
||||||
|
templateurl="/static/js/directives/ui/app-public-view/channels-list.html"></cor-table-col>
|
||||||
|
</cor-table>
|
||||||
|
</div>
|
||||||
|
</div> <!-- /releases -->
|
||||||
|
|
||||||
|
<div ng-show="$ctrl.currentTab == 'settings'" ng-if="$ctrl.repository.can_admin">
|
||||||
|
<div class="repo-panel-settings" repository="$ctrl.repository" is-enabled="$ctrl.settingsShown"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Side bar -->
|
||||||
|
<div class="col-md-3 side-bar">
|
||||||
|
<div>
|
||||||
|
<visibility-indicator repository="$ctrl.repository"></visibility-indicator>
|
||||||
|
</div>
|
||||||
|
<div ng-if="$ctrl.repository.is_public">{{ $ctrl.repository.namespace }} is sharing this application publicly</div>
|
||||||
|
<div ng-if="!$ctrl.repository.is_public">This application is private and only visible to those with permission</div>
|
||||||
|
|
||||||
|
<div class="sidebar-table" ng-if="$ctrl.repository.channels.length">
|
||||||
|
<h4>Latest Channels</h4>
|
||||||
|
<cor-table table-data="$ctrl.repository.channels" table-item-title="channels" filter-fields="['name']" compact="true" max-display-count="3">
|
||||||
|
<cor-table-col datafield="name" sortfield="name" title="Name"
|
||||||
|
templateurl="/static/js/directives/ui/app-public-view/channel-name.html"></cor-table-col>
|
||||||
|
<cor-table-col datafield="release" sortfield="release" title="Current Release"></cor-table-col>
|
||||||
|
</cor-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-table" ng-if="$ctrl.repository.releases.length">
|
||||||
|
<h4>Latest Releases</h4>
|
||||||
|
<cor-table table-data="$ctrl.repository.releases" table-item-title="releases" filter-fields="['name']" compact="true" max-display-count="3">
|
||||||
|
<cor-table-col datafield="name" sortfield="name" title="Name"></cor-table-col>
|
||||||
|
<cor-table-col datafield="last_modified" sortfield="last_modified"
|
||||||
|
title="Created"
|
||||||
|
selected="true" dataKind="datetime"
|
||||||
|
templateurl="/static/js/directives/ui/app-public-view/last-modified.html"></cor-table-col>
|
||||||
|
</cor-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Input, Component } from 'angular-ts-decorators';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that displays the public information associated with an application repository.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'appPublicView',
|
||||||
|
templateUrl: '/static/js/directives/ui/app-public-view/app-public-view.component.html'
|
||||||
|
})
|
||||||
|
export class AppPublicViewComponent implements ng.IComponentController {
|
||||||
|
@Input('<') public repository: any;
|
||||||
|
private currentTab: string = 'description';
|
||||||
|
private settingsShown: number = 0;
|
||||||
|
|
||||||
|
constructor(private Config: any) {
|
||||||
|
this.updateDescription = this.updateDescription.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDescription(content: string) {
|
||||||
|
this.repository.description = content;
|
||||||
|
this.repository.put();
|
||||||
|
}
|
||||||
|
|
||||||
|
public showTab(tab: string): void {
|
||||||
|
this.currentTab = tab;
|
||||||
|
if (tab == 'settings') {
|
||||||
|
this.settingsShown++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
<channel-icon name="item.name"></channel-icon><span style="vertical-align: middle; margin-left: 6px;">{{ item.name }}</span>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<span ng-repeat="channel_name in item.channels">
|
||||||
|
<channel-icon name="channel_name"></channel-icon>
|
||||||
|
</span>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<span data-title="{{ item.last_modified | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}" bs-tooltip>
|
||||||
|
<span am-time-ago="item.last_modified"></span>
|
||||||
|
</span>
|
|
@ -0,0 +1,7 @@
|
||||||
|
<span class="channel-icon-element" data-title="{{ $ctrl.name }}" bs-tooltip>
|
||||||
|
<span class="hexagon" ng-style="{'background-color': $ctrl.color($ctrl.name)}">
|
||||||
|
<span class="before" ng-style="{'border-bottom-color': $ctrl.color($ctrl.name)}"></span>
|
||||||
|
<span class="after" ng-style="{'border-top-color': $ctrl.color($ctrl.name)}"></span>
|
||||||
|
</span>
|
||||||
|
<b>{{ $ctrl.initial($ctrl.name) }}</b>
|
||||||
|
</span>
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { Input, Component } from 'angular-ts-decorators';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that displays the icon of a channel.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'channelIcon',
|
||||||
|
templateUrl: '/static/js/directives/ui/channel-icon/channel-icon.component.html',
|
||||||
|
})
|
||||||
|
export class ChannelIconComponent implements ng.IComponentController {
|
||||||
|
@Input('<') public name: string;
|
||||||
|
|
||||||
|
private colors: any;
|
||||||
|
|
||||||
|
constructor(Config: any, private md5: any) {
|
||||||
|
this.colors = Config['CHANNEL_COLORS'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private initial(name: string): string {
|
||||||
|
if (name == 'alpha') {
|
||||||
|
return 'α';
|
||||||
|
}
|
||||||
|
if (name == 'beta') {
|
||||||
|
return 'β';
|
||||||
|
}
|
||||||
|
if (name == 'stable') {
|
||||||
|
return 'S';
|
||||||
|
}
|
||||||
|
return name[0].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private color(name: string): string {
|
||||||
|
if (name == 'alpha') {
|
||||||
|
return this.colors[0];
|
||||||
|
}
|
||||||
|
if (name == 'beta') {
|
||||||
|
return this.colors[1];
|
||||||
|
}
|
||||||
|
if (name == 'stable') {
|
||||||
|
return this.colors[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash: string = this.md5.createHash(name);
|
||||||
|
var num: number = parseInt(hash.substr(0, 4));
|
||||||
|
return this.colors[num % this.colors.length];
|
||||||
|
}
|
||||||
|
}
|
40
static/js/directives/ui/cor-table/cor-table-col.component.ts
Normal file
40
static/js/directives/ui/cor-table/cor-table-col.component.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { Input, Component } from 'angular-ts-decorators';
|
||||||
|
import { CorTableComponent } from './cor-table.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a column (optionally sortable) in the table.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'corTableCol',
|
||||||
|
template: '',
|
||||||
|
require: {
|
||||||
|
parent: '^^corTable'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class CorTableColumn implements ng.IComponentController {
|
||||||
|
@Input('@') public title: string;
|
||||||
|
@Input('@') public templateurl: string;
|
||||||
|
|
||||||
|
@Input('@') public datafield: string;
|
||||||
|
@Input('@') public sortfield: string;
|
||||||
|
@Input('@') public selected: string;
|
||||||
|
@Input('@') public dataKind: string;
|
||||||
|
|
||||||
|
private parent: CorTableComponent;
|
||||||
|
|
||||||
|
public $onInit(): void {
|
||||||
|
this.parent.addColumn(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public isNumeric(): boolean {
|
||||||
|
return this.dataKind == 'datetime';
|
||||||
|
}
|
||||||
|
|
||||||
|
public processColumnForOrdered(tableService: any, value: any): any {
|
||||||
|
if (this.dataKind == 'datetime') {
|
||||||
|
return tableService.getReversedTimestamp(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
42
static/js/directives/ui/cor-table/cor-table.component.html
Normal file
42
static/js/directives/ui/cor-table/cor-table.component.html
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
<div class="cor-table-element">
|
||||||
|
<span ng-transclude/>
|
||||||
|
|
||||||
|
<!-- Filter -->
|
||||||
|
<div class="co-top-bar" ng-if="$ctrl.compact != 'true'">
|
||||||
|
<span class="co-filter-box with-options" ng-if="$ctrl.tableData.length && $ctrl.filterFields.length">
|
||||||
|
<span class="filter-message" ng-if="$ctrl.options.filter">
|
||||||
|
Showing {{ $ctrl.orderedData.entries.length }} of {{ $ctrl.tableData.length }} {{ $ctrl.tableItemTitle }}
|
||||||
|
</span>
|
||||||
|
<input class="form-control" type="text" ng-model="$ctrl.options.filter"
|
||||||
|
placeholder="Filter {{ $ctrl.tableItemTitle }}..." ng-change="$ctrl.refreshOrder()">
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div class="empty" ng-if="!$ctrl.tableData.length && $ctrl.compact != 'true'">
|
||||||
|
<div class="empty-primary-msg">No {{ $ctrl.tableItemTitle }} found.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<table class="co-table" ng-show="$ctrl.tableData.length">
|
||||||
|
<thead>
|
||||||
|
<td ng-repeat="col in $ctrl.columns" ng-class="$ctrl.tablePredicateClass(col, $ctrl.options)">
|
||||||
|
<a ng-click="$ctrl.setOrder(col)">{{ col.title }}</a>
|
||||||
|
</td>
|
||||||
|
</thead>
|
||||||
|
<tbody ng-repeat="item in $ctrl.orderedData.visibleEntries | limitTo:$ctrl.maxDisplayCount">
|
||||||
|
<tr>
|
||||||
|
<td ng-repeat="col in $ctrl.columns">
|
||||||
|
<div ng-include="col.templateurl" ng-if="col.templateurl"></div>
|
||||||
|
<div ng-if="!col.templateurl">{{ item[col.datafield] }}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="empty" ng-if="!$ctrl.orderedData.entries.length && $ctrl.tableData.length"
|
||||||
|
style="margin-top: 20px;">
|
||||||
|
<div class="empty-primary-msg">No matching {{ $ctrl.tableItemTitle }} found.</div>
|
||||||
|
<div class="empty-secondary-msg">Try adjusting your filter above.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
82
static/js/directives/ui/cor-table/cor-table.component.ts
Normal file
82
static/js/directives/ui/cor-table/cor-table.component.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import { Input, Component } from 'angular-ts-decorators';
|
||||||
|
import { CorTableColumn } from './cor-table-col.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that displays a table of information, with optional filtering and automatic sorting.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'corTable',
|
||||||
|
templateUrl: '/static/js/directives/ui/cor-table/cor-table.component.html',
|
||||||
|
transclude: true,
|
||||||
|
})
|
||||||
|
export class CorTableComponent implements ng.IComponentController {
|
||||||
|
@Input('=') public tableData: any[];
|
||||||
|
@Input('@') public tableItemTitle: string;
|
||||||
|
@Input('<') public filterFields: string[];
|
||||||
|
@Input('@') public compact: string;
|
||||||
|
@Input('<') public maxDisplayCount: number;
|
||||||
|
|
||||||
|
private columns: CorTableColumn[];
|
||||||
|
private orderedData: any;
|
||||||
|
private options: any;
|
||||||
|
|
||||||
|
constructor(private TableService: any) {
|
||||||
|
this.columns = [];
|
||||||
|
this.options = {
|
||||||
|
'filter': '',
|
||||||
|
'reverse': false,
|
||||||
|
'predicate': '',
|
||||||
|
'page': 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public addColumn(col: CorTableColumn): void {
|
||||||
|
this.columns.push(col);
|
||||||
|
|
||||||
|
if (col.selected == 'true') {
|
||||||
|
this.options['predicate'] = col.datafield;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setOrder(col: CorTableColumn): void {
|
||||||
|
this.TableService.orderBy(col.datafield, this.options);
|
||||||
|
this.refreshOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
private tablePredicateClass(col: CorTableColumn, options: any) {
|
||||||
|
return this.TableService.tablePredicateClass(col.datafield, this.options.predicate,
|
||||||
|
this.options.reverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshOrder(): void {
|
||||||
|
var columnMap = {};
|
||||||
|
this.columns.forEach(function(col) {
|
||||||
|
columnMap[col.datafield] = col;
|
||||||
|
});
|
||||||
|
|
||||||
|
var filterCols = this.columns.filter(function(col) {
|
||||||
|
return !!col.sortfield;
|
||||||
|
}).map((col) => (col.datafield));
|
||||||
|
|
||||||
|
var numericCols = this.columns.filter(function(col) {
|
||||||
|
return col.isNumeric();
|
||||||
|
}).map((col) => (col.datafield));
|
||||||
|
|
||||||
|
var processed = this.tableData.map((item) => {
|
||||||
|
var keys = Object.keys(item);
|
||||||
|
var newObj = {};
|
||||||
|
for (var i = 0; i < keys.length; ++i) {
|
||||||
|
var key = keys[i];
|
||||||
|
if (columnMap[key]) {
|
||||||
|
newObj[key] = columnMap[key].processColumnForOrdered(this.TableService, item[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newObj;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.orderedData = this.TableService.buildOrderedItems(processed, this.options,
|
||||||
|
filterCols, numericCols);
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,7 +14,8 @@ angular.module('quay').directive('repoListGrid', function () {
|
||||||
namespace: '=namespace',
|
namespace: '=namespace',
|
||||||
starToggled: '&starToggled',
|
starToggled: '&starToggled',
|
||||||
hideTitle: '=hideTitle',
|
hideTitle: '=hideTitle',
|
||||||
hideNamespaces: '=hideNamespaces'
|
hideNamespaces: '=hideNamespaces',
|
||||||
|
repoKind: '@repoKind'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, UserService) {
|
controller: function($scope, $element, UserService) {
|
||||||
$scope.isOrganization = function(namespace) {
|
$scope.isOrganization = function(namespace) {
|
||||||
|
|
|
@ -11,7 +11,8 @@ angular.module('quay').directive('repoListTable', function () {
|
||||||
scope: {
|
scope: {
|
||||||
'repositoriesResources': '=repositoriesResources',
|
'repositoriesResources': '=repositoriesResources',
|
||||||
'namespaces': '=namespaces',
|
'namespaces': '=namespaces',
|
||||||
'starToggled': '&starToggled'
|
'starToggled': '&starToggled',
|
||||||
|
'repoKind': '@repoKind'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, $filter, TableService, UserService) {
|
controller: function($scope, $element, $filter, TableService, UserService) {
|
||||||
$scope.repositories = null;
|
$scope.repositories = null;
|
||||||
|
|
|
@ -12,6 +12,7 @@ angular.module('quay').directive('repoListView', function () {
|
||||||
namespaces: '=namespaces',
|
namespaces: '=namespaces',
|
||||||
starredRepositories: '=starredRepositories',
|
starredRepositories: '=starredRepositories',
|
||||||
starToggled: '&starToggled',
|
starToggled: '&starToggled',
|
||||||
|
repoKind: '@repoKind'
|
||||||
},
|
},
|
||||||
controller: function($scope, $element, CookieService) {
|
controller: function($scope, $element, CookieService) {
|
||||||
$scope.resources = [];
|
$scope.resources = [];
|
||||||
|
|
18
static/js/directives/ui/repository-title.js
Normal file
18
static/js/directives/ui/repository-title.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* An element which displays the title of a repository (either 'repository' or 'application').
|
||||||
|
*/
|
||||||
|
angular.module('quay').directive('repositoryTitle', function () {
|
||||||
|
var directiveDefinitionObject = {
|
||||||
|
priority: 0,
|
||||||
|
templateUrl: '/static/directives/repository-title.html',
|
||||||
|
replace: false,
|
||||||
|
transclude: true,
|
||||||
|
restrict: 'C',
|
||||||
|
scope: {
|
||||||
|
'repository': '<repository'
|
||||||
|
},
|
||||||
|
controller: function($scope, $element) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return directiveDefinitionObject;
|
||||||
|
});
|
|
@ -0,0 +1,4 @@
|
||||||
|
<span class="visibility-indicator-component-element" ng-class="{'public': $ctrl.repository.is_public, 'private': !$ctrl.repository.is_public}">
|
||||||
|
<span class="public" ng-if="$ctrl.repository.is_public">Public</span>
|
||||||
|
<span class="private" ng-if="!$ctrl.repository.is_public">Private</span>
|
||||||
|
</span>
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Input, Component } from 'angular-ts-decorators';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that displays a box with "Public" or "Private", depending on the visibility
|
||||||
|
* of the repository.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'visibilityIndicator',
|
||||||
|
templateUrl: '/static/js/directives/ui/visibility-indicator/visibility-indicator.component.html'
|
||||||
|
})
|
||||||
|
export class VisibilityIndicatorComponent implements ng.IComponentController {
|
||||||
|
@Input('<') public repository: any;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
82
static/js/pages/app-list.js
Normal file
82
static/js/pages/app-list.js
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
(function() {
|
||||||
|
/**
|
||||||
|
* Application listing page. Shows all applications for all visibile namespaces.
|
||||||
|
*/
|
||||||
|
angular.module('quayPages').config(['pages', function(pages) {
|
||||||
|
pages.create('app-list', 'app-list.html', AppListCtrl, {
|
||||||
|
'newLayout': true,
|
||||||
|
'title': 'Applications',
|
||||||
|
'description': 'View and manage applications'
|
||||||
|
})
|
||||||
|
}]);
|
||||||
|
|
||||||
|
function AppListCtrl($scope, $sanitize, $q, Restangular, UserService, ApiService, Features) {
|
||||||
|
$scope.namespace = null;
|
||||||
|
$scope.page = 1;
|
||||||
|
$scope.publicPageCount = null;
|
||||||
|
$scope.allRepositories = {};
|
||||||
|
$scope.loading = true;
|
||||||
|
$scope.resources = [];
|
||||||
|
$scope.Features = Features;
|
||||||
|
|
||||||
|
// When loading the UserService, if the user is logged in, create a list of
|
||||||
|
// relevant namespaces and collect the relevant repositories.
|
||||||
|
UserService.updateUserIn($scope, function(user) {
|
||||||
|
$scope.loading = false;
|
||||||
|
if (!user.anonymous) {
|
||||||
|
// Add our user to our list of namespaces.
|
||||||
|
$scope.namespaces = [{
|
||||||
|
'name': user.username,
|
||||||
|
'avatar': user.avatar
|
||||||
|
}];
|
||||||
|
|
||||||
|
// Add each org to our list of namespaces.
|
||||||
|
user.organizations.map(function(org) {
|
||||||
|
$scope.namespaces.push({
|
||||||
|
'name': org.name,
|
||||||
|
'avatar': org.avatar
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the repos.
|
||||||
|
loadRepos();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.isOrganization = function(namespace) {
|
||||||
|
return !!UserService.getOrganization(namespace);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Finds a duplicate repo if it exists. If it doesn't, inserts the repo.
|
||||||
|
var findDuplicateRepo = function(repo) {
|
||||||
|
var found = $scope.allRepositories[repo.namespace + '/' + repo.name];
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
} else {
|
||||||
|
$scope.allRepositories[repo.namespace + '/' + repo.name] = repo;
|
||||||
|
return repo;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var loadRepos = function() {
|
||||||
|
if (!$scope.user || $scope.user.anonymous || $scope.namespaces.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.namespaces.map(function(namespace) {
|
||||||
|
var options = {
|
||||||
|
'namespace': namespace.name,
|
||||||
|
'last_modified': true,
|
||||||
|
'popularity': true,
|
||||||
|
'repo_kind': 'application'
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace.repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) {
|
||||||
|
return resp.repositories.map(findDuplicateRepo);
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.resources.push(namespace.repositories);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
39
static/js/pages/app-view.js
Normal file
39
static/js/pages/app-view.js
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
(function() {
|
||||||
|
/**
|
||||||
|
* Application view page.
|
||||||
|
*/
|
||||||
|
angular.module('quayPages').config(['pages', function(pages) {
|
||||||
|
pages.create('app-view', 'app-view.html', AppViewCtrl, {
|
||||||
|
'newLayout': true,
|
||||||
|
'title': '{{ namespace }}/{{ name }}',
|
||||||
|
'description': 'Application {{ namespace }}/{{ name }}'
|
||||||
|
});
|
||||||
|
}]);
|
||||||
|
|
||||||
|
function AppViewCtrl($scope, $routeParams, $location, $timeout, ApiService, UserService, AngularPollChannel, ImageLoaderService, CookieService) {
|
||||||
|
$scope.namespace = $routeParams.namespace;
|
||||||
|
$scope.name = $routeParams.name;
|
||||||
|
|
||||||
|
$scope.viewScope = {};
|
||||||
|
$scope.settingsShown = 0;
|
||||||
|
|
||||||
|
$scope.showSettings = function() {
|
||||||
|
$scope.settingsShown++;
|
||||||
|
};
|
||||||
|
|
||||||
|
var loadRepository = function() {
|
||||||
|
var params = {
|
||||||
|
'repository': $scope.namespace + '/' + $scope.name,
|
||||||
|
'repo_kind': 'application',
|
||||||
|
'includeStats': true
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.repositoryResource = ApiService.getRepoAsResource(params).get(function(repo) {
|
||||||
|
$scope.repository = repo;
|
||||||
|
$scope.viewScope.repository = repo;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
loadRepository();
|
||||||
|
}
|
||||||
|
})();
|
|
@ -20,7 +20,8 @@
|
||||||
'is_public': 0,
|
'is_public': 0,
|
||||||
'description': '',
|
'description': '',
|
||||||
'initialize': '',
|
'initialize': '',
|
||||||
'name': $routeParams['name']
|
'name': $routeParams['name'],
|
||||||
|
'repo_kind': 'image'
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.changeNamespace = function(namespace) {
|
$scope.changeNamespace = function(namespace) {
|
||||||
|
@ -54,13 +55,19 @@
|
||||||
'namespace': repo.namespace,
|
'namespace': repo.namespace,
|
||||||
'repository': repo.name,
|
'repository': repo.name,
|
||||||
'visibility': repo.is_public == '1' ? 'public' : 'private',
|
'visibility': repo.is_public == '1' ? 'public' : 'private',
|
||||||
'description': repo.description
|
'description': repo.description,
|
||||||
|
'repo_kind': repo.repo_kind
|
||||||
};
|
};
|
||||||
|
|
||||||
ApiService.createRepo(data).then(function(created) {
|
ApiService.createRepo(data).then(function(created) {
|
||||||
$scope.creating = false;
|
$scope.creating = false;
|
||||||
$scope.created = created;
|
$scope.created = created;
|
||||||
|
|
||||||
|
if (repo.repo_kind == 'application') {
|
||||||
|
$location.path('/application/' + created.namespace + '/' + created.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Start the build if applicable.
|
// Start the build if applicable.
|
||||||
if ($scope.repo.initialize == 'dockerfile' || $scope.repo.initialize == 'zipfile') {
|
if ($scope.repo.initialize == 'dockerfile' || $scope.repo.initialize == 'zipfile') {
|
||||||
$scope.createdForBuild = created;
|
$scope.createdForBuild = created;
|
||||||
|
|
|
@ -40,6 +40,12 @@ export class QuayRoutes {
|
||||||
}
|
}
|
||||||
|
|
||||||
routeBuilder
|
routeBuilder
|
||||||
|
// Application View
|
||||||
|
.route('/application/:namespace/:name', 'app-view')
|
||||||
|
|
||||||
|
// Repo List
|
||||||
|
.route('/application/', 'app-list')
|
||||||
|
|
||||||
// Repository View
|
// Repository View
|
||||||
.route('/repository/:namespace/:name', 'repo-view')
|
.route('/repository/:namespace/:name', 'repo-view')
|
||||||
.route('/repository/:namespace/:name/tag/:tag', 'repo-view')
|
.route('/repository/:namespace/:name/tag/:tag', 'repo-view')
|
||||||
|
|
|
@ -12,6 +12,11 @@ import { ManageTriggerCustomGitComponent } from './directives/ui/manage-trigger-
|
||||||
import { ManageTriggerGithostComponent } from './directives/ui/manage-trigger-githost/manage-trigger-githost.component';
|
import { ManageTriggerGithostComponent } from './directives/ui/manage-trigger-githost/manage-trigger-githost.component';
|
||||||
import { LinearWorkflowComponent } from './directives/ui/linear-workflow/linear-workflow.component';
|
import { LinearWorkflowComponent } from './directives/ui/linear-workflow/linear-workflow.component';
|
||||||
import { LinearWorkflowSectionComponent } from './directives/ui/linear-workflow/linear-workflow-section.component';
|
import { LinearWorkflowSectionComponent } from './directives/ui/linear-workflow/linear-workflow-section.component';
|
||||||
|
import { AppPublicViewComponent } from './directives/ui/app-public-view/app-public-view.component';
|
||||||
|
import { VisibilityIndicatorComponent } from './directives/ui/visibility-indicator/visibility-indicator.component';
|
||||||
|
import { CorTableComponent } from './directives/ui/cor-table/cor-table.component';
|
||||||
|
import { CorTableColumn } from './directives/ui/cor-table/cor-table-col.component';
|
||||||
|
import { ChannelIconComponent } from './directives/ui/channel-icon/channel-icon.component';
|
||||||
import { QuayConfig } from './quay-config.module';
|
import { QuayConfig } from './quay-config.module';
|
||||||
import { QuayRun } from './quay-run.module';
|
import { QuayRun } from './quay-run.module';
|
||||||
import { BuildServiceImpl } from './services/build/build.service.impl';
|
import { BuildServiceImpl } from './services/build/build.service.impl';
|
||||||
|
@ -37,6 +42,11 @@ import { DataFileServiceImpl } from './services/datafile/datafile.service.impl';
|
||||||
ManageTriggerGithostComponent,
|
ManageTriggerGithostComponent,
|
||||||
LinearWorkflowComponent,
|
LinearWorkflowComponent,
|
||||||
LinearWorkflowSectionComponent,
|
LinearWorkflowSectionComponent,
|
||||||
|
AppPublicViewComponent,
|
||||||
|
VisibilityIndicatorComponent,
|
||||||
|
CorTableComponent,
|
||||||
|
CorTableColumn,
|
||||||
|
ChannelIconComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ViewArrayImpl,
|
ViewArrayImpl,
|
||||||
|
|
30
static/partials/app-list.html
Normal file
30
static/partials/app-list.html
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<div class="repo-list page-content">
|
||||||
|
<div class="cor-title">
|
||||||
|
<span class="cor-title-link"></span>
|
||||||
|
<span class="cor-title-content">Applications</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div class="cor-loader" ng-if="loading || user.beforeload"></div>
|
||||||
|
|
||||||
|
<!-- Not signed in -->
|
||||||
|
<div class="co-main-content-panel" ng-if="!loading && user.anonymous && !user.beforeload">
|
||||||
|
<!-- The user is not logged in -->
|
||||||
|
<div class="cor-container signin-container row">
|
||||||
|
<!-- Sign In -->
|
||||||
|
<div class="user-setup" redirect-url="redirectUrl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signed in -->
|
||||||
|
<div class="row" ng-if="!loading && !user.anonymous">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="repo-list-panel co-main-content-panel">
|
||||||
|
<div class="repo-list-view" namespaces="namespaces" repo-kind="application">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
5
static/partials/app-view.html
Normal file
5
static/partials/app-view.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<div class="resource-view repository-view"
|
||||||
|
resource="repositoryResource"
|
||||||
|
error-message="'Application not found'">
|
||||||
|
<app-public-view repository="viewScope.repository"></app-public-view>
|
||||||
|
</div>
|
|
@ -31,6 +31,14 @@
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="new-header">
|
<div class="new-header">
|
||||||
<div class="namespace-selector-header">
|
<div class="namespace-selector-header">
|
||||||
|
<span quay-show="Features.APP_REGISTRY">
|
||||||
|
<span class="visible-xs xs-label">Repository Kind:</span>
|
||||||
|
<select class="form-control repo-kind-select" ng-model="repo.repo_kind">
|
||||||
|
<option value="image">Container Image Repository</option>
|
||||||
|
<option value="application">Application Repository</option>
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
|
||||||
<span class="visible-xs xs-label">Namespace:</span>
|
<span class="visible-xs xs-label">Namespace:</span>
|
||||||
<span class="namespace-selector" user="user" namespace="repo.namespace" require-create="true"></span>
|
<span class="namespace-selector" user="user" namespace="repo.namespace" require-create="true"></span>
|
||||||
|
|
||||||
|
@ -49,6 +57,7 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
@ -96,9 +105,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ng-show="repo.is_public == '1' || (!planRequired && !checkingPlan)">
|
<div ng-show="repo.is_public == '1' || (!planRequired && !checkingPlan)">
|
||||||
<div class="row" ng-show="Features.BUILD_SUPPORT">
|
<div class="row" ng-show="Features.BUILD_SUPPORT && repo.repo_kind == 'image'">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">Initialize repository</div>
|
<div class="section-title">Initialize repository</div>
|
||||||
|
@ -132,7 +140,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" ng-show="repo.initialize == 'dockerfile' || repo.initialize == 'zipfile'">
|
<div class="row" ng-show="(repo.initialize == 'dockerfile' || repo.initialize == 'zipfile') && repo.repo_kind == 'image'">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-title">Initialize from Dockerfile</div>
|
<div class="section-title">Initialize from Dockerfile</div>
|
||||||
|
@ -149,7 +157,7 @@
|
||||||
|
|
||||||
<div class="row"
|
<div class="row"
|
||||||
ng-repeat="type in TriggerService.getTypes()"
|
ng-repeat="type in TriggerService.getTypes()"
|
||||||
ng-if="TriggerService.isEnabled(type) && repo.initialize == type">
|
ng-if="TriggerService.isEnabled(type) && repo.initialize == type && repo.repo_kind == 'image'">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="co-alert co-alert-info">
|
<div class="co-alert co-alert-info">
|
||||||
You will be redirected to authorize for {{ TriggerService.getTitle(type) }} once the repository has been created
|
You will be redirected to authorize for {{ TriggerService.getTitle(type) }} once the repository has been created
|
||||||
|
|
|
@ -57,7 +57,8 @@
|
||||||
<div class="repo-list-panel co-main-content-panel">
|
<div class="repo-list-panel co-main-content-panel">
|
||||||
<div class="repo-list-view" namespaces="namespaces"
|
<div class="repo-list-view" namespaces="namespaces"
|
||||||
star-toggled="starToggled(repository)"
|
star-toggled="starToggled(repository)"
|
||||||
starred-repositories="starred_repositories">
|
starred-repositories="starred_repositories"
|
||||||
|
repo-kind="image">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -776,7 +776,7 @@ class RegistryTestsMixin(object):
|
||||||
data = {
|
data = {
|
||||||
'repository': 'someapprepo',
|
'repository': 'someapprepo',
|
||||||
'visibility': 'private',
|
'visibility': 'private',
|
||||||
'kind': 'application',
|
'repo_kind': 'application',
|
||||||
'description': 'test app repo',
|
'description': 'test app repo',
|
||||||
}
|
}
|
||||||
self.conduct('POST', '/api/v1/repository', json_data=data, expected_code=201)
|
self.conduct('POST', '/api/v1/repository', json_data=data, expected_code=201)
|
||||||
|
|
|
@ -1919,6 +1919,20 @@ class TestCreateRepo(ApiTestCase):
|
||||||
self.assertEquals(ADMIN_ACCESS_USER, json['namespace'])
|
self.assertEquals(ADMIN_ACCESS_USER, json['namespace'])
|
||||||
self.assertEquals('newrepo', json['name'])
|
self.assertEquals('newrepo', json['name'])
|
||||||
|
|
||||||
|
def test_create_app_repo(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
json = self.postJsonResponse(RepositoryList,
|
||||||
|
data=dict(repository='newrepo',
|
||||||
|
visibility='public',
|
||||||
|
description='',
|
||||||
|
repo_kind='application'),
|
||||||
|
expected_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
self.assertEquals(ADMIN_ACCESS_USER, json['namespace'])
|
||||||
|
self.assertEquals('newrepo', json['name'])
|
||||||
|
self.assertEquals('application', json['kind'])
|
||||||
|
|
||||||
def test_createrepo_underorg(self):
|
def test_createrepo_underorg(self):
|
||||||
self.login(ADMIN_ACCESS_USER)
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
@ -1935,6 +1949,26 @@ class TestCreateRepo(ApiTestCase):
|
||||||
|
|
||||||
|
|
||||||
class TestListRepos(ApiTestCase):
|
class TestListRepos(ApiTestCase):
|
||||||
|
def test_list_app_repos(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
# Create an app repo.
|
||||||
|
self.postJsonResponse(RepositoryList,
|
||||||
|
data=dict(repository='newrepo',
|
||||||
|
visibility='public',
|
||||||
|
description='',
|
||||||
|
repo_kind='application'),
|
||||||
|
expected_code=201)
|
||||||
|
|
||||||
|
json = self.getJsonResponse(RepositoryList,
|
||||||
|
params=dict(namespace=ADMIN_ACCESS_USER,
|
||||||
|
public=False,
|
||||||
|
repo_kind='application'))
|
||||||
|
|
||||||
|
self.assertEquals(1, len(json['repositories']))
|
||||||
|
self.assertEquals('application', json['repositories'][0]['kind'])
|
||||||
|
|
||||||
|
|
||||||
def test_listrepos_asguest(self):
|
def test_listrepos_asguest(self):
|
||||||
# Queries: Base + the list query
|
# Queries: Base + the list query
|
||||||
with assert_query_count(BASE_QUERY_COUNT + 1):
|
with assert_query_count(BASE_QUERY_COUNT + 1):
|
||||||
|
|
Reference in a new issue