Merge pull request #2489 from coreos-inc/apps-ui

Add basic user interface for application repos
This commit is contained in:
josephschorr 2017-04-05 12:58:38 -04:00 committed by GitHub
commit a6954f246c
47 changed files with 1009 additions and 106 deletions

View file

@ -20,7 +20,7 @@ CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY',
'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT',
'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION',
'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):
@ -437,3 +437,10 @@ class DefaultConfig(object):
FEATURE_TEAM_SYNCING = False
TEAM_RESYNC_STALE_TIME = '30m'
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']

View file

@ -387,7 +387,7 @@ class PreOCIModel(DockerRegistryV2DataInterface):
return [_tag_view(tag) for tag in tags_query]
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))
query = query.limit(limit).offset(offset)
return [_repository_for_repo(repo) for repo in query]

View file

@ -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')
# 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.
if start_id is not None:

View file

@ -276,8 +276,13 @@ def unstar_repository(user, repository):
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. """
try:
repo_kind = Repository.kind.get_id(kind_filter)
except RepositoryKind.DoesNotExist:
raise DataModelException('Unknown kind of repository')
query = (Repository
.select(Repository, User, Visibility, Repository.id.alias('rid'))
.join(Star)
@ -285,8 +290,7 @@ def get_user_starred_repositories(user, repo_kind='image'):
.join(User)
.switch(Repository)
.join(Visibility)
.where(Star.user == user,
Repository.kind == Repository.kind.get_id(repo_kind)))
.where(Star.user == user, Repository.kind == repo_kind))
return query
@ -320,7 +324,7 @@ def get_when_last_modified(repository_ids):
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):
""" 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.
query = query.switch(Repository).distinct().join(RepositoryPermission, JOIN_LEFT_OUTER)
query = _basequery.filter_to_repos_for_user(query, username, namespace, repo_kind, include_public,
start_id=start_id)
query = _basequery.filter_to_repos_for_user(query, username, namespace, kind_filter,
include_public, start_id=start_id)
if limit is not None:
query = query.limit(limit).order_by(SQL('rid'))

View file

@ -121,13 +121,17 @@ def create_app_release(repo, tag_name, manifest_data, digest, force=False):
ManifestBlob.create(manifest=manifest, blob=blob)
return tag
def get_releases(repo, media_type=None):
""" Returns an array of Tag.name for a repo, can filter by media_type. """
def get_release_objs(repo, media_type=None):
""" Returns an array of Tag for a repo, with optional filtering by media_type. """
release_query = (Tag
.select()
.where(Tag.repository == repo,
Tag.tag_kind == Tag.tag_kind.get_id("release")))
if 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)]

View file

@ -4,12 +4,13 @@ import logging
import datetime
import features
from collections import defaultdict
from datetime import timedelta, datetime
from flask import request, abort
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,
require_repo_read, require_repo_write, require_repo_admin,
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
@ -77,10 +78,10 @@ class RepositoryList(ApiResource):
'type': 'string',
'description': 'Markdown encoded description for the repository',
},
'kind': {
'type': 'string',
'repo_kind': {
'type': ['string', 'null'],
'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):
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_kind=kind)
repo.description = req['description']
@ -126,7 +127,8 @@ class RepositoryList(ApiResource):
'namespace': namespace_name}, repo=repo)
return {
'namespace': namespace_name,
'name': repository_name
'name': repository_name,
'kind': kind,
}, 201
raise Unauthorized()
@ -144,6 +146,7 @@ class RepositoryList(ApiResource):
type=truthy_bool, default=False)
@query_param('popularity', 'Whether to include the repository\'s popularity metric.',
type=truthy_bool, default=False)
@query_param('repo_kind', 'The kind of repositories to return', type=str, default='image')
@page_support()
def get(self, page_token, parsed_args):
""" 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
next_page_token = None
repos = None
repo_kind = parsed_args['repo_kind']
# Lookup the requested repositories (either starred or non-starred.)
if parsed_args['starred']:
@ -169,14 +173,15 @@ class RepositoryList(ApiResource):
def can_view_repo(repo):
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)]
elif parsed_args['namespace']:
# Repositories filtered by namespace do not need pagination (their results are fairly small),
# so we just do the lookup directly.
repos = list(model.repository.get_visible_repositories(username=username,
include_public=parsed_args['public'],
namespace=parsed_args['namespace']))
namespace=parsed_args['namespace'],
kind_filter=repo_kind))
else:
# 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
@ -188,7 +193,8 @@ class RepositoryList(ApiResource):
repo_query = model.repository.get_visible_repositories(username=username,
include_public=parsed_args['public'],
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,
id_alias='rid')
@ -217,6 +223,7 @@ class RepositoryList(ApiResource):
'name': repo_obj.name,
'description': repo_obj.description,
'is_public': repo_obj.visibility_id == model.repository.get_public_repo_visibility().id,
'kind': repo_kind,
}
repo_id = repo_obj.rid
@ -266,6 +273,55 @@ class Repository(RepositoryParamResource):
"""Fetch the specified 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):
tag_info = {
'name': tag.name,
@ -282,63 +338,54 @@ class Repository(RepositoryParamResource):
return tag_info
repo = model.repository.get_repository(namespace, repository)
stats = None
if repo:
tags = model.tag.list_repository_tags(namespace, repository, include_storage=True)
manifests = model.tag.get_tag_manifests(tags)
tags = model.tag.list_repository_tags(namespace, repository, include_storage=True)
manifests = model.tag.get_tag_manifests(tags)
tag_dict = {tag.name: tag_view(tag, manifests.get(tag.id)) for tag in tags}
can_write = ModifyRepositoryPermission(namespace, repository).can()
can_admin = AdministerRepositoryPermission(namespace, repository).can()
tag_dict = {tag.name: tag_view(tag, manifests.get(tag.id)) for tag in tags}
if parsed_args['includeStats']:
stats = []
found_dates = {}
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)
start_date = datetime.now() - timedelta(days=MAX_DAYS_IN_3_MONTHS)
counts = model.log.get_repository_action_counts(repo, start_date)
for count in counts:
stats.append({
'date': count.date.isoformat(),
'count': count.count,
})
if parsed_args['includeStats']:
stats = []
found_dates = {}
found_dates['%s/%s' % (count.date.month, count.date.day)] = True
start_date = datetime.now() - timedelta(days=MAX_DAYS_IN_3_MONTHS)
counts = model.log.get_repository_action_counts(repo, start_date)
for count in counts:
# Fill in any missing stats with zeros.
for day in range(1, MAX_DAYS_IN_3_MONTHS):
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': count.date.isoformat(),
'count': count.count,
'date': day_date.date().isoformat(),
'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.
for day in range(1, MAX_DAYS_IN_3_MONTHS):
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,
})
if stats is not None:
repo_data['stats'] = stats
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 '',
}
return repo_data
if stats is not None:
repo_data['stats'] = stats
return repo_data
raise NotFound()
@require_repo_write
@nickname('updateRepo')
@ -361,7 +408,6 @@ class Repository(RepositoryParamResource):
@require_repo_admin
@nickname('deleteRepository')
@disallow_for_app_repositories
def delete(self, namespace, repository):
""" Delete a repository. """
model.repository.purge_repository(namespace, repository)

View file

@ -28,7 +28,6 @@ TRIGGER_ARGS = {'trigger_uuid': '1234'}
FIELD_ARGS = {'trigger_uuid': '1234', 'field_name': 'foobar'}
@pytest.mark.parametrize('resource, method, params', [
(Repository, 'delete', None),
(RepositoryBuildList, 'get', None),
(RepositoryBuildList, 'post', None),
(RepositoryBuildResource, 'get', BUILD_ARGS),

View file

@ -191,6 +191,14 @@ def buildtrigger(path, trigger):
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/')
@no_cache
def starred():

View 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;
}

View 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;
}

View 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;
}

View file

@ -7,6 +7,19 @@
padding: 20px;
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 {

View file

@ -30,6 +30,7 @@
<!-- Signed in -->
<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="/tutorial/" quay-section="tutorial">Tutorial</a></li>
<li><a href="https://docs.quay.io/" ng-safenewtab>Docs</a></li>

View file

@ -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-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>

View file

@ -28,7 +28,11 @@
<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>
<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 }}
</a>
</div>

View file

@ -38,7 +38,7 @@
<td class="hidden-xs"
ng-class="tablePredicateClass('is_starred', options.predicate, options.reverse)"
style="width: 70px"
ng-if="loggedIn">
ng-if="loggedIn && repoKind != 'application'">
<a ng-click="orderBy('is_starred')">Star</a>
</td>
</thead>
@ -47,7 +47,11 @@
<tr ng-repeat="repository in orderedRepositories.entries | slice:(reposPerPage * options.page):(reposPerPage * (options.page + 1))">
<td class="repo-name-icon">
<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="name">{{ ::repository.name }}</span>
</a>
@ -62,7 +66,7 @@
<span class="strength-indicator" value="::repository.popularity" maximum="::maxPopularity"
log-base="10"></span>
</td>
<td ng-show="loggedIn">
<td ng-show="loggedIn && repoKind != 'application'">
<span class="repo-star" repository="::repository"
star-toggled="starToggled({'repository': repository})"></span>
</td>

View file

@ -12,7 +12,8 @@
<!-- Table View -->
<div ng-if="showAsList">
<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>
@ -22,7 +23,8 @@
<div class="repo-list-grid" repositories-resource="starredRepositories"
starred="true"
star-toggled="starToggled({'repository': repository})"
ng-if="starredRepositories">
ng-if="starredRepositories"
repo-kind="{{ repoKind }}">
</div>
<!-- User and Org Repository Listings -->
@ -31,7 +33,8 @@
starred="false" namespace="namespace"
star-toggled="starToggled({'repository': repository})"
hide-title="namespaces.length == 1"
hide-namespaces="true">
hide-namespaces="true"
repo-kind="{{ repoKind }}">
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
<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 -->
<div class="co-panel" id="repoPermissions">
@ -11,7 +11,7 @@
</div>
<!-- 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="panel-body" style="padding-top: 5px;">
<div class="repository-tokens-table" repository="repository" has-tokens="hasTokens" is-enabled="isEnabled"></div>
@ -19,18 +19,22 @@
</div>
<!-- Events and Notifications -->
<div class="repository-events-table" repository="repository"
is-enabled="isEnabled"></div>
<div ng-if="repository.kind == 'image'">
<div class="repository-events-table" repository="repository"
is-enabled="isEnabled"></div>
</div>
<!-- Visibility settings -->
<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 ng-show="repository">
<!-- Public/Private -->
<div class="panel-body panel-section lock-section" ng-if="!repository.is_public">
<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')">
<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">
<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">
<i class="fa fa-lock"></i>Make Private
@ -56,17 +60,19 @@
<!-- Delete repository -->
<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 ng-show="repository">
<div class="panel-body panel-section">
<div class="co-alert co-alert-danger">
<button class="btn btn-danger delete-btn" ng-click="askDelete()">
<i class="fa fa-trash"></i>
Delete Repository
Delete <span class="repository-title" repository="repository"></span>
</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>
@ -74,7 +80,7 @@
<!-- 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="cor-loader" ng-show="!repository"></div>
<div ng-show="repository">
@ -123,12 +129,12 @@
<div class="cor-confirm-dialog"
dialog-context="deleteRepoInfo"
dialog-action="deleteRepo(info, callback)"
dialog-title="Delete Repository"
dialog-action-title="Delete Repository">
dialog-title="Delete"
dialog-action-title="Delete">
<div class="co-alert co-alert-danger" style="margin-bottom: 10px;">
This action cannot be undone!
</div>
Continue with deletion of this repository?
Continue with deletion of this <span class="repository-title" repository="repository"></span>?
</div>
</div>

View 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>

View file

@ -15,6 +15,10 @@ angular.module('quay').directive('repoPanelSettings', function () {
controller: function($scope, $element, ApiService, Config) {
$scope.deleteDialogCounter = 0;
var getTitle = function(repo) {
return repo.kind == 'application' ? 'application' : 'image';
};
$scope.getBadgeFormat = function(format, repository) {
if (!repository) { return ''; }
@ -52,7 +56,8 @@ angular.module('quay').directive('repoPanelSettings', function () {
'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() {
callback(true);
@ -62,9 +67,11 @@ angular.module('quay').directive('repoPanelSettings', function () {
}, errorHandler);
};
$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; }
$scope.changeAccess(newAccess);
});
@ -81,7 +88,7 @@ angular.module('quay').directive('repoPanelSettings', function () {
ApiService.changeRepoVisibility(visibility, params).then(function() {
$scope.repository.is_public = newAccess == 'public';
}, ApiService.errorDisplay('Could not change repository visibility'));
}, ApiService.errorDisplay('Could not change visibility'));
};
}
};

View file

@ -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>

View file

@ -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++;
}
}
}

View file

@ -0,0 +1 @@
<channel-icon name="item.name"></channel-icon><span style="vertical-align: middle; margin-left: 6px;">{{ item.name }}</span>

View file

@ -0,0 +1,3 @@
<span ng-repeat="channel_name in item.channels">
<channel-icon name="channel_name"></channel-icon>
</span>

View file

@ -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>

View file

@ -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>

View file

@ -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];
}
}

View 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;
}
}

View 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>

View 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);
}
}

View file

@ -14,7 +14,8 @@ angular.module('quay').directive('repoListGrid', function () {
namespace: '=namespace',
starToggled: '&starToggled',
hideTitle: '=hideTitle',
hideNamespaces: '=hideNamespaces'
hideNamespaces: '=hideNamespaces',
repoKind: '@repoKind'
},
controller: function($scope, $element, UserService) {
$scope.isOrganization = function(namespace) {

View file

@ -11,7 +11,8 @@ angular.module('quay').directive('repoListTable', function () {
scope: {
'repositoriesResources': '=repositoriesResources',
'namespaces': '=namespaces',
'starToggled': '&starToggled'
'starToggled': '&starToggled',
'repoKind': '@repoKind'
},
controller: function($scope, $element, $filter, TableService, UserService) {
$scope.repositories = null;

View file

@ -12,6 +12,7 @@ angular.module('quay').directive('repoListView', function () {
namespaces: '=namespaces',
starredRepositories: '=starredRepositories',
starToggled: '&starToggled',
repoKind: '@repoKind'
},
controller: function($scope, $element, CookieService) {
$scope.resources = [];

View 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;
});

View file

@ -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>

View file

@ -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() {
}
}

View 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);
});
};
}
})();

View 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();
}
})();

View file

@ -20,7 +20,8 @@
'is_public': 0,
'description': '',
'initialize': '',
'name': $routeParams['name']
'name': $routeParams['name'],
'repo_kind': 'image'
};
$scope.changeNamespace = function(namespace) {
@ -54,13 +55,19 @@
'namespace': repo.namespace,
'repository': repo.name,
'visibility': repo.is_public == '1' ? 'public' : 'private',
'description': repo.description
'description': repo.description,
'repo_kind': repo.repo_kind
};
ApiService.createRepo(data).then(function(created) {
$scope.creating = false;
$scope.created = created;
if (repo.repo_kind == 'application') {
$location.path('/application/' + created.namespace + '/' + created.name);
return;
}
// Start the build if applicable.
if ($scope.repo.initialize == 'dockerfile' || $scope.repo.initialize == 'zipfile') {
$scope.createdForBuild = created;

View file

@ -40,6 +40,12 @@ export class QuayRoutes {
}
routeBuilder
// Application View
.route('/application/:namespace/:name', 'app-view')
// Repo List
.route('/application/', 'app-list')
// Repository View
.route('/repository/:namespace/:name', 'repo-view')
.route('/repository/:namespace/:name/tag/:tag', 'repo-view')

View file

@ -12,6 +12,11 @@ import { ManageTriggerCustomGitComponent } from './directives/ui/manage-trigger-
import { ManageTriggerGithostComponent } from './directives/ui/manage-trigger-githost/manage-trigger-githost.component';
import { LinearWorkflowComponent } from './directives/ui/linear-workflow/linear-workflow.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 { QuayRun } from './quay-run.module';
import { BuildServiceImpl } from './services/build/build.service.impl';
@ -37,6 +42,11 @@ import { DataFileServiceImpl } from './services/datafile/datafile.service.impl';
ManageTriggerGithostComponent,
LinearWorkflowComponent,
LinearWorkflowSectionComponent,
AppPublicViewComponent,
VisibilityIndicatorComponent,
CorTableComponent,
CorTableColumn,
ChannelIconComponent,
],
providers: [
ViewArrayImpl,

View 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>

View 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>

View file

@ -31,6 +31,14 @@
<div class="section">
<div class="new-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="namespace-selector" user="user" namespace="repo.namespace" require-create="true"></span>
@ -49,6 +57,7 @@
</span>
</div>
</div>
</div>
<div class="section">
@ -96,9 +105,8 @@
</div>
</div>
</div>
<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="section">
<div class="section-title">Initialize repository</div>
@ -132,7 +140,7 @@
</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="section">
<div class="section-title">Initialize from Dockerfile</div>
@ -149,7 +157,7 @@
<div class="row"
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="co-alert co-alert-info">
You will be redirected to authorize for {{ TriggerService.getTitle(type) }} once the repository has been created

View file

@ -57,7 +57,8 @@
<div class="repo-list-panel co-main-content-panel">
<div class="repo-list-view" namespaces="namespaces"
star-toggled="starToggled(repository)"
starred-repositories="starred_repositories">
starred-repositories="starred_repositories"
repo-kind="image">
</div>
</div>
</div>

View file

@ -776,7 +776,7 @@ class RegistryTestsMixin(object):
data = {
'repository': 'someapprepo',
'visibility': 'private',
'kind': 'application',
'repo_kind': 'application',
'description': 'test app repo',
}
self.conduct('POST', '/api/v1/repository', json_data=data, expected_code=201)

View file

@ -1919,6 +1919,20 @@ class TestCreateRepo(ApiTestCase):
self.assertEquals(ADMIN_ACCESS_USER, json['namespace'])
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):
self.login(ADMIN_ACCESS_USER)
@ -1935,6 +1949,26 @@ class TestCreateRepo(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):
# Queries: Base + the list query
with assert_query_count(BASE_QUERY_COUNT + 1):