Merge pull request #2416 from coreos-inc/new-tags-ui
Updated Tags and Tag History UI
This commit is contained in:
commit
9297373bd6
45 changed files with 1250 additions and 639 deletions
|
@ -82,6 +82,16 @@ def filter_tags_have_repository_event(query, event):
|
|||
.where(RepositoryNotification.event == event)
|
||||
.order_by(RepositoryTag.lifetime_start_ts.desc()))
|
||||
|
||||
|
||||
def get_tag_manifests(tags):
|
||||
""" Returns a map from tag ID to its associated manifest, if any. """
|
||||
if not tags:
|
||||
return dict()
|
||||
|
||||
manifests = TagManifest.select().where(TagManifest.tag << [t.id for t in tags])
|
||||
return {manifest.tag_id:manifest for manifest in manifests}
|
||||
|
||||
|
||||
def list_repository_tags(namespace_name, repository_name, include_hidden=False,
|
||||
include_storage=False):
|
||||
to_select = (RepositoryTag, Image)
|
||||
|
@ -296,6 +306,7 @@ def list_repository_tag_history(repo_obj, page=1, size=100, specific_tag=None):
|
|||
query = (RepositoryTag
|
||||
.select(RepositoryTag, Image)
|
||||
.join(Image)
|
||||
.switch(RepositoryTag)
|
||||
.where(RepositoryTag.repository == repo_obj)
|
||||
.where(RepositoryTag.hidden == False)
|
||||
.order_by(RepositoryTag.lifetime_start_ts.desc(), RepositoryTag.name)
|
||||
|
@ -306,35 +317,76 @@ def list_repository_tag_history(repo_obj, page=1, size=100, specific_tag=None):
|
|||
query = query.where(RepositoryTag.name == specific_tag)
|
||||
|
||||
tags = list(query)
|
||||
return tags[0:size], len(tags) > size
|
||||
if not tags:
|
||||
return [], {}, False
|
||||
|
||||
manifest_map = get_tag_manifests(tags)
|
||||
return tags[0:size], manifest_map, len(tags) > size
|
||||
|
||||
|
||||
def revert_tag(repo_obj, tag_name, docker_image_id):
|
||||
""" Reverts a tag to a specific image ID. """
|
||||
# Verify that the image ID already existed under this repository under the
|
||||
# tag.
|
||||
try:
|
||||
(RepositoryTag
|
||||
.select()
|
||||
.join(Image)
|
||||
.where(RepositoryTag.repository == repo_obj)
|
||||
.where(RepositoryTag.name == tag_name)
|
||||
.where(Image.docker_image_id == docker_image_id)
|
||||
.get())
|
||||
except RepositoryTag.DoesNotExist:
|
||||
raise DataModelException('Cannot revert to unknown or invalid image')
|
||||
def restore_tag_to_manifest(repo_obj, tag_name, manifest_digest):
|
||||
""" Restores a tag to a specific manifest digest. """
|
||||
with db_transaction():
|
||||
# Verify that the manifest digest already existed under this repository under the
|
||||
# tag.
|
||||
try:
|
||||
manifest = (TagManifest
|
||||
.select(TagManifest, RepositoryTag, Image)
|
||||
.join(RepositoryTag)
|
||||
.join(Image)
|
||||
.where(RepositoryTag.repository == repo_obj)
|
||||
.where(RepositoryTag.name == tag_name)
|
||||
.where(TagManifest.digest == manifest_digest)
|
||||
.get())
|
||||
except TagManifest.DoesNotExist:
|
||||
raise DataModelException('Cannot restore to unknown or invalid digest')
|
||||
|
||||
return create_or_update_tag(repo_obj.namespace_user.username, repo_obj.name, tag_name,
|
||||
docker_image_id, reversion=True)
|
||||
# Lookup the existing image, if any.
|
||||
try:
|
||||
existing_image = get_repo_tag_image(repo_obj, tag_name)
|
||||
except DataModelException:
|
||||
existing_image = None
|
||||
|
||||
docker_image_id = manifest.tag.image.docker_image_id
|
||||
store_tag_manifest(repo_obj.namespace_user.username, repo_obj.name, tag_name, docker_image_id,
|
||||
manifest_digest, manifest.json_data, reversion=True)
|
||||
return existing_image
|
||||
|
||||
|
||||
def restore_tag_to_image(repo_obj, tag_name, docker_image_id):
|
||||
""" Restores a tag to a specific image ID. """
|
||||
with db_transaction():
|
||||
# Verify that the image ID already existed under this repository under the
|
||||
# tag.
|
||||
try:
|
||||
(RepositoryTag
|
||||
.select()
|
||||
.join(Image)
|
||||
.where(RepositoryTag.repository == repo_obj)
|
||||
.where(RepositoryTag.name == tag_name)
|
||||
.where(Image.docker_image_id == docker_image_id)
|
||||
.get())
|
||||
except RepositoryTag.DoesNotExist:
|
||||
raise DataModelException('Cannot restore to unknown or invalid image')
|
||||
|
||||
# Lookup the existing image, if any.
|
||||
try:
|
||||
existing_image = get_repo_tag_image(repo_obj, tag_name)
|
||||
except DataModelException:
|
||||
existing_image = None
|
||||
|
||||
create_or_update_tag(repo_obj.namespace_user.username, repo_obj.name, tag_name,
|
||||
docker_image_id, reversion=True)
|
||||
return existing_image
|
||||
|
||||
|
||||
def store_tag_manifest(namespace, repo_name, tag_name, docker_image_id, manifest_digest,
|
||||
manifest_data):
|
||||
manifest_data, reversion=False):
|
||||
""" Stores a tag manifest for a specific tag name in the database. Returns the TagManifest
|
||||
object, as well as a boolean indicating whether the TagManifest was created.
|
||||
"""
|
||||
with db_transaction():
|
||||
tag = create_or_update_tag(namespace, repo_name, tag_name, docker_image_id)
|
||||
tag = create_or_update_tag(namespace, repo_name, tag_name, docker_image_id, reversion=reversion)
|
||||
|
||||
try:
|
||||
manifest = TagManifest.get(digest=manifest_digest)
|
||||
|
|
|
@ -17,7 +17,7 @@ from endpoints.api import (ApiResource, resource, method_metadata, nickname, tru
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PARAM_REGEX = re.compile(r'<([\w]+:)?([\w]+)>')
|
||||
PARAM_REGEX = re.compile(r'<([^:>]+:)*([\w]+)>')
|
||||
|
||||
|
||||
TYPE_CONVERTER = {
|
||||
|
|
|
@ -49,9 +49,9 @@ class RepositoryManifestLabels(RepositoryParamResource):
|
|||
'description': 'The value for the label',
|
||||
},
|
||||
'media_type': {
|
||||
'type': ['string'],
|
||||
'type': ['string', 'null'],
|
||||
'description': 'The media type for this label',
|
||||
'enum': ALLOWED_LABEL_MEDIA_TYPES,
|
||||
'enum': ALLOWED_LABEL_MEDIA_TYPES + [None],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -259,7 +259,7 @@ class Repository(RepositoryParamResource):
|
|||
"""Fetch the specified repository."""
|
||||
logger.debug('Get repo: %s/%s' % (namespace, repository))
|
||||
|
||||
def tag_view(tag):
|
||||
def tag_view(tag, manifest):
|
||||
tag_info = {
|
||||
'name': tag.name,
|
||||
'image_id': tag.image.docker_image_id,
|
||||
|
@ -270,13 +270,18 @@ class Repository(RepositoryParamResource):
|
|||
last_modified = format_date(datetime.fromtimestamp(tag.lifetime_start_ts))
|
||||
tag_info['last_modified'] = last_modified
|
||||
|
||||
if manifest is not None:
|
||||
tag_info['manifest_digest'] = manifest.digest
|
||||
|
||||
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)
|
||||
tag_dict = {tag.name: tag_view(tag) for tag in tags}
|
||||
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()
|
||||
|
||||
|
|
|
@ -41,19 +41,22 @@ class ListRepositoryTags(RepositoryParamResource):
|
|||
if tag.lifetime_end_ts > 0:
|
||||
tag_info['end_ts'] = tag.lifetime_end_ts
|
||||
|
||||
if tag.id in manifest_map:
|
||||
tag_info['manifest_digest'] = manifest_map[tag.id].digest
|
||||
|
||||
return tag_info
|
||||
|
||||
specific_tag = parsed_args.get('specificTag') or None
|
||||
|
||||
page = max(1, parsed_args.get('page', 1))
|
||||
limit = min(100, max(1, parsed_args.get('limit', 50)))
|
||||
tags, has_additional = model.tag.list_repository_tag_history(repo, page=page, size=limit,
|
||||
specific_tag=specific_tag)
|
||||
tags, manifest_map, more = model.tag.list_repository_tag_history(repo, page=page, size=limit,
|
||||
specific_tag=specific_tag)
|
||||
|
||||
return {
|
||||
'tags': [tag_view(tag) for tag in tags],
|
||||
'page': page,
|
||||
'has_additional': has_additional,
|
||||
'has_additional': more,
|
||||
}
|
||||
|
||||
|
||||
|
@ -177,15 +180,15 @@ class RepositoryTagImages(RepositoryParamResource):
|
|||
|
||||
|
||||
|
||||
@resource('/v1/repository/<apirepopath:repository>/tag/<tag>/revert')
|
||||
@resource('/v1/repository/<apirepopath:repository>/tag/<tag>/restore')
|
||||
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||||
@path_param('tag', 'The name of the tag')
|
||||
class RevertTag(RepositoryParamResource):
|
||||
""" Resource for reverting a repository tag back to a previous image. """
|
||||
class RestoreTag(RepositoryParamResource):
|
||||
""" Resource for restoring a repository tag back to a previous image. """
|
||||
schemas = {
|
||||
'RevertTag': {
|
||||
'RestoreTag': {
|
||||
'type': 'object',
|
||||
'description': 'Reverts a tag to a specific image',
|
||||
'description': 'Restores a tag to a specific image',
|
||||
'required': [
|
||||
'image',
|
||||
],
|
||||
|
@ -194,32 +197,45 @@ class RevertTag(RepositoryParamResource):
|
|||
'type': 'string',
|
||||
'description': 'Image identifier to which the tag should point',
|
||||
},
|
||||
'manifest_digest': {
|
||||
'type': 'string',
|
||||
'description': 'If specified, the manifest digest that should be used',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@require_repo_write
|
||||
@nickname('revertTag')
|
||||
@validate_json_request('RevertTag')
|
||||
@nickname('restoreTag')
|
||||
@validate_json_request('RestoreTag')
|
||||
def post(self, namespace, repository, tag):
|
||||
""" Reverts a repository tag back to a previous image in the repository. """
|
||||
try:
|
||||
tag_image = model.tag.get_tag_image(namespace, repository, tag)
|
||||
except model.DataModelException:
|
||||
raise NotFound()
|
||||
""" Restores a repository tag back to a previous image in the repository. """
|
||||
repo = model.repository.get_repository(namespace, repository)
|
||||
|
||||
# Revert the tag back to the previous image.
|
||||
# Restore the tag back to the previous image.
|
||||
image_id = request.get_json()['image']
|
||||
model.tag.revert_tag(tag_image.repository, tag, image_id)
|
||||
manifest_digest = request.get_json().get('manifest_digest', None)
|
||||
if manifest_digest is not None:
|
||||
existing_image = model.tag.restore_tag_to_manifest(repo, tag, manifest_digest)
|
||||
else:
|
||||
existing_image = model.tag.restore_tag_to_image(repo, tag, image_id)
|
||||
|
||||
# Log the reversion.
|
||||
# Log the reversion/restoration.
|
||||
username = get_authenticated_user().username
|
||||
log_data = {
|
||||
'username': username,
|
||||
'repo': repository,
|
||||
'tag': tag,
|
||||
'image': image_id,
|
||||
}
|
||||
|
||||
if existing_image is not None:
|
||||
log_data['original_image'] = existing_image.docker_image_id
|
||||
|
||||
log_action('revert_tag', namespace,
|
||||
{'username': username, 'repo': repository, 'tag': tag,
|
||||
'image': image_id, 'original_image': tag_image.docker_image_id},
|
||||
repo=model.repository.get_repository(namespace, repository))
|
||||
log_data, repo=model.repository.get_repository(namespace, repository))
|
||||
|
||||
return {
|
||||
'image_id': image_id,
|
||||
'original_image_id': tag_image.docker_image_id
|
||||
'original_image_id': existing_image.docker_image_id if existing_image else None,
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ EXTERNAL_JS = [
|
|||
'cdn.ravenjs.com/3.1.0/angular/raven.min.js',
|
||||
'cdn.jsdelivr.net/cal-heatmap/3.3.10/cal-heatmap.min.js',
|
||||
'cdnjs.cloudflare.com/ajax/libs/angular-recaptcha/3.2.1/angular-recaptcha.min.js',
|
||||
'cdnjs.cloudflare.com/ajax/libs/ng-tags-input/3.1.1/ng-tags-input.min.js',
|
||||
]
|
||||
|
||||
EXTERNAL_CSS = [
|
||||
|
@ -28,6 +29,7 @@ EXTERNAL_CSS = [
|
|||
's3.amazonaws.com/cdn.core-os.net/icons/core-icons.css',
|
||||
'cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.37/css/bootstrap-datetimepicker.min.css',
|
||||
'cdn.jsdelivr.net/cal-heatmap/3.3.10/cal-heatmap.css',
|
||||
'cdnjs.cloudflare.com/ajax/libs/ng-tags-input/3.1.1/ng-tags-input.min.css',
|
||||
]
|
||||
|
||||
EXTERNAL_FONTS = [
|
||||
|
|
|
@ -510,6 +510,7 @@ def populate_database(minimal=False, with_storage=False):
|
|||
first_label = model.label.create_manifest_label(tag_manifest, 'foo', 'bar', 'manifest')
|
||||
model.label.create_manifest_label(tag_manifest, 'foo', 'baz', 'api')
|
||||
model.label.create_manifest_label(tag_manifest, 'anotherlabel', '1234', 'internal')
|
||||
model.label.create_manifest_label(tag_manifest, 'jsonlabel', '{"hey": "there"}', 'internal')
|
||||
|
||||
label_metadata = {
|
||||
'key': 'foo',
|
||||
|
|
|
@ -110,15 +110,29 @@
|
|||
.repo-panel-tags-element .security-scan-col .has-vulns.Defcon1 .highest-vuln {
|
||||
}
|
||||
|
||||
|
||||
.repo-panel-tags-element .security-scan-col .has-vulns {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.repo-panel-tags-element .security-scan-col .has-vulns .donut-chart {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.repo-panel-tags-element .security-scan-col .has-vulns a {
|
||||
color: ablack;
|
||||
}
|
||||
|
||||
.repo-panel-tags-element .other-vulns {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.arepo-panel-tags-element .tag-span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 250px;
|
||||
display: inline-block;
|
||||
.repo-panel-tags-element tr.expanded-view td {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.repo-panel-tags-element .labels-col {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
|
|
5
static/css/directives/ui/donut-chart.css
Normal file
5
static/css/directives/ui/donut-chart.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
.donut-chart-element svg {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
}
|
|
@ -1,4 +1,29 @@
|
|||
.image-link {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.image-link a {
|
||||
font-family: Consolas, "Lucida Console", Monaco, monospace;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.image-link .id-label {
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
background-color: #eee;
|
||||
border-radius: 5px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
color: black !important;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.image-link .id-label.cas {
|
||||
background-color: #e8f1f6;
|
||||
}
|
40
static/css/directives/ui/label-input.css
Normal file
40
static/css/directives/ui/label-input.css
Normal file
|
@ -0,0 +1,40 @@
|
|||
.label-input-element .tags {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
|
||||
}
|
||||
|
||||
.label-input-element .tags.focused {
|
||||
border-color: #66afe9;
|
||||
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075), 0 0 8px rgba(102,175,233,0.6);
|
||||
}
|
||||
|
||||
.label-input-element .tags .tag-item {
|
||||
display: inline-block;
|
||||
border-radius: 9px;
|
||||
background: #eee;
|
||||
font-size: 12px;
|
||||
border: 0px;
|
||||
padding: 0px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.label-input-element tags-input .tags .input {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.label-input-element .tags .tag-item.selected {
|
||||
background: #31b0d5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.label-input-element .tags .tag-item button {
|
||||
background: transparent;
|
||||
color: #000;
|
||||
opacity: .4;
|
||||
}
|
9
static/css/directives/ui/label-list.css
Normal file
9
static/css/directives/ui/label-list.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.label-list-element .label-view {
|
||||
margin-right: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.label-list-element .empty-list {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
25
static/css/directives/ui/label-view.css
Normal file
25
static/css/directives/ui/label-view.css
Normal file
|
@ -0,0 +1,25 @@
|
|||
.label-view-element {
|
||||
display: inline-block;
|
||||
padding: 1px;
|
||||
border-radius: 9px;
|
||||
background-color: #eee;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
font-size: 12px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.label-view-element .kind {
|
||||
text-transform: uppercase;
|
||||
font-size: 8px;
|
||||
color: #aaa;
|
||||
margin-right: 2px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.label-view-element .value {
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
8
static/css/directives/ui/manifest-label-list.css
Normal file
8
static/css/directives/ui/manifest-label-list.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
.manifest-label-list-element {
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.manifest-label-list-element .none {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
}
|
|
@ -1,7 +1,73 @@
|
|||
.repo-tag-history-element .history-list {
|
||||
margin: 10px;
|
||||
border-left: 2px solid #eee;
|
||||
margin-right: 150px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-table thead {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-table tbody td {
|
||||
border-bottom: 0px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-table td.revert-col {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-table td.revert-col .tag-span {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-table td.icon-col {
|
||||
border-bottom: 0px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
line-height: 32px;
|
||||
vertical-align: middle;
|
||||
padding: 0px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-table td.history-col {
|
||||
width: 520px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-table td.datetime-col {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-table td.icon-col:after {
|
||||
content: " ";
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
left: 15px;
|
||||
width: 2px;
|
||||
background-color: #ddd;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-table td.icon-col .history-icon {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
left: 0px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-table td.icon-col .datetime-icon {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
left: 9px;
|
||||
z-index: 2;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-row {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry {
|
||||
|
@ -12,16 +78,21 @@
|
|||
transition: all 350ms ease-in-out;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry .history-text {
|
||||
transition: transform 350ms ease-in-out, opacity 350ms ease-in-out;
|
||||
.repo-tag-history-element .history-entry .history-description,
|
||||
.repo-tag-history-element .history-entry .history-datetime,
|
||||
.repo-tag-history-element .history-entry .history-revert {
|
||||
transition: height 350ms ease-in-out, opacity 350ms ease-in-out;
|
||||
overflow: hidden;
|
||||
height: 40px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .co-filter-box {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-datetime-small {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.repo-tag-history-element .history-list {
|
||||
|
@ -36,13 +107,43 @@
|
|||
float: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry .history-description {
|
||||
overflow: visible;
|
||||
margin-bottom: 10px;
|
||||
margin-left: 20px;
|
||||
display: block;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-row {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-datetime-small {
|
||||
font-size: 12px;
|
||||
margin-left: 20px;
|
||||
margin-top: 10px;
|
||||
color: #aaa;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry.filtered-mismatch {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry.filtered-mismatch .history-text {
|
||||
.repo-tag-history-element .history-entry.filtered-mismatch .history-datetime {
|
||||
height: 18px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry.filtered-mismatch .history-revert {
|
||||
height: 18px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry.filtered-mismatch .history-description {
|
||||
height: 18px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
@ -64,19 +165,12 @@
|
|||
content: "\f073";
|
||||
font-family: FontAwesome;
|
||||
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
top: 1px;
|
||||
left: -9px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry .history-icon {
|
||||
position: absolute;
|
||||
left: -17px;
|
||||
top: -4px;
|
||||
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
@ -100,6 +194,11 @@
|
|||
font-family: FontAwesome;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry.recreate .history-icon:before {
|
||||
content: "\f10d";
|
||||
font-family: core-icons;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry.revert .history-icon:before {
|
||||
content: "\f0e2";
|
||||
font-family: FontAwesome;
|
||||
|
@ -122,6 +221,10 @@
|
|||
background-color: #98df8a;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry.current.recreate .history-icon {
|
||||
background-color: #ba8adf;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry.current.delete .history-icon {
|
||||
background-color: #ff9896;
|
||||
}
|
||||
|
@ -135,20 +238,25 @@
|
|||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
background: #eee;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
color: black;
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
||||
.repo-tag-history-element .history-entry .tag-span span {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry .tag-span.checked {
|
||||
background: #F6FCFF;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry .tag-span:before {
|
||||
content: "\f02b";
|
||||
font-family: FontAwesome;
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
.repo-tag-history-element .history-entry .image-link {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.repo-tag-history-element .history-entry .history-description {
|
||||
|
@ -156,6 +264,5 @@
|
|||
}
|
||||
|
||||
.repo-tag-history-element .history-entry .history-datetime {
|
||||
font-size: 12px;
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
}
|
|
@ -20,4 +20,9 @@
|
|||
list-style: none;
|
||||
display: inline-block;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.tag-operations-dialog .label-section {
|
||||
margin-bottom: 10px;
|
||||
padding: 6px;
|
||||
}
|
1
static/directives/donut-chart.html
Normal file
1
static/directives/donut-chart.html
Normal file
|
@ -0,0 +1 @@
|
|||
<span class="donut-chart-element" ng-style="{'line-height': size + 'px'}"></span>
|
|
@ -1,2 +1,17 @@
|
|||
<a bo-href-i="/repository/{{ repository.namespace }}/{{ repository.name }}/image/{{ imageId }}"
|
||||
class="image-link-element" bindonce>{{ imageId.substr(0, 12) }}</a>
|
||||
<span>
|
||||
<a bo-href-i="/repository/{{ repository.namespace }}/{{ repository.name }}/image/{{ imageId }}"
|
||||
class="image-link-element" bindonce>
|
||||
<span class="id-label" ng-if="!hasSHA256(manifestDigest)"
|
||||
data-title="The Docker V1 ID for this image. This ID is not content addressable nor is it stable across pulls."
|
||||
data-container="body"
|
||||
bs-tooltip>V1ID</span>
|
||||
|
||||
<span class="id-label cas" ng-if="hasSHA256(manifestDigest)"
|
||||
data-title="The content-addressable SHA256 hash of this tag."
|
||||
data-container="body"
|
||||
bs-tooltip>SHA256</span>
|
||||
|
||||
<span ng-if="!hasSHA256(manifestDigest)">{{ imageId.substr(0, 12) }}</span>
|
||||
<span ng-if="hasSHA256(manifestDigest)">{{ getShortDigest(manifestDigest) }}</span>
|
||||
</a>
|
||||
</span>
|
||||
|
|
12
static/directives/label-input.html
Normal file
12
static/directives/label-input.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
<div class="label-input-element">
|
||||
<tags-input class="quay-labels"
|
||||
ng-model="tags"
|
||||
display-property="keyValue"
|
||||
placeholder="git-sha=123456ab"
|
||||
add-on-paste="true"
|
||||
add-on-comma="false"
|
||||
spellcheck="false"
|
||||
replace-spaces-with-dashes="false"
|
||||
allowed-tags-pattern="^[0-9A-Za-z/\-_.]+=.+$"
|
||||
enable-editing-last-tag="true"></tags-input>
|
||||
</div>
|
10
static/directives/label-list.html
Normal file
10
static/directives/label-list.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
<div class="label-list-element">
|
||||
<div class="label-view"
|
||||
ng-repeat="label in labels"
|
||||
expand="{{expand}}"
|
||||
label="label">
|
||||
</div>
|
||||
<div class="empty-list" ng-if="!labels.length">
|
||||
No labels found
|
||||
</div>
|
||||
</div>
|
8
static/directives/label-view.html
Normal file
8
static/directives/label-view.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
<a class="label-view-element" ng-click="viewLabelValue()">
|
||||
<span class="kind">{{ getKind(label) }}</span>
|
||||
<span class="label-value">
|
||||
<span class="key">{{ label.key }}</span>
|
||||
<span class="equals">=</span>
|
||||
<span class="value">{{ label.value }}</span>
|
||||
</span>
|
||||
</a>
|
11
static/directives/manifest-label-list.html
Normal file
11
static/directives/manifest-label-list.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
<div class="manifest-label-list-element">
|
||||
<div class="cor-loader-inline" ng-if="repository && manifestDigest && !labels && !loadError"></div>
|
||||
<div class="none" ng-if="repository && !manifestDigest && !loadError">
|
||||
This tag does not have an associated manifest
|
||||
</div>
|
||||
<div class="none" ng-if="repository && manifestDigest && loadError">
|
||||
Could not load labels for this manifest
|
||||
</div>
|
||||
<div class="label-list" labels="labels"
|
||||
ng-if="repository && manifestDigest && labels && !loadError"></div>
|
||||
</div>
|
|
@ -11,41 +11,116 @@
|
|||
<div class="empty-secondary-msg">There has not been any recent tag activity on this repository.</div>
|
||||
</div>
|
||||
|
||||
<div class="history-entry" ng-repeat="entry in historyEntries"
|
||||
ng-class="getEntryClasses(entry, filter)">
|
||||
<div class="history-date-break" ng-if="entry.date_break">
|
||||
{{ entry.date | amDateFormat:'dddd, MMMM Do YYYY' }}
|
||||
</div>
|
||||
<div ng-if="!entry.date_break">
|
||||
<div class="history-icon-container"><div class="history-icon"></div></div>
|
||||
<div class="history-text">
|
||||
<div class="history-description">
|
||||
<span class="tag-span"
|
||||
ng-click="showHistory(true, entry.tag_name)">{{ entry.tag_name }}</span>
|
||||
<span ng-switch on="entry.action">
|
||||
<span ng-switch-when="create">
|
||||
was created pointing to image <span class="image-link" repository="repository" image-id="entry.docker_image_id"></span>
|
||||
<table class="co-table" ng-if="historyEntries.length">
|
||||
<thead class="hidden-xs">
|
||||
<td class="icon-col"></td>
|
||||
<td class="history-col">Tag Change</td>
|
||||
<td class="datetime-col hidden-sm hidden-xs">Date/Time</td>
|
||||
<td class="revert-col hidden-sm hidden-xs"><span ng-if="repository.can_write">Restore</span></td>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="height: 20px;"><td colspan="4"></td></tr>
|
||||
<tr ng-repeat="entry in historyEntries" class="history-entry" ng-class="getEntryClasses(entry, filter)">
|
||||
<td ng-if="entry.date_break" class="icon-col">
|
||||
<i class="fa fa-calendar datetime-icon"></i>
|
||||
</td>
|
||||
|
||||
<td class="history-row" ng-if="entry.date_break" colspan="2">
|
||||
{{ entry.date | amDateFormat:'dddd, MMMM Do YYYY' }}
|
||||
</td>
|
||||
|
||||
<td ng-if="!entry.date_break" class="icon-col">
|
||||
<div class="history-icon" data-title="{{ isCurrent(entry) ? 'Current view of this tag' : 'The value of this tag has changed since this action' }}" data-container="body" bs-tooltip></div>
|
||||
</td>
|
||||
<td ng-if="!entry.date_break" class="history-col">
|
||||
<div class="history-description">
|
||||
<span class="tag-span" data-title="{{ entry.tag_name }}" bs-tooltip><span>{{ entry.tag_name }}</span></span>
|
||||
<span ng-switch on="entry.action">
|
||||
<span ng-switch-when="recreate">
|
||||
was recreated pointing to
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.docker_image_id"
|
||||
manifest-digest="entry.manifest_digest"></span>
|
||||
</span>
|
||||
<span ng-switch-when="create">
|
||||
was created pointing to
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.docker_image_id"
|
||||
manifest-digest="entry.manifest_digest"></span>
|
||||
</span>
|
||||
<span ng-switch-when="delete">
|
||||
was deleted
|
||||
</span>
|
||||
<span ng-switch-when="move">
|
||||
was moved to
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.docker_image_id"
|
||||
manifest-digest="entry.manifest_digest"></span>
|
||||
from
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.old_docker_image_id"
|
||||
manifest-digest="entry.old_manifest_digest"></span>
|
||||
</span>
|
||||
<span ng-switch-when="revert">
|
||||
was reverted to
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.docker_image_id"
|
||||
manifest-digest="entry.manifest_digest"></span>
|
||||
from
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.old_docker_image_id"
|
||||
manifest-digest="entry.old_manifest_digest"></span>
|
||||
</span>
|
||||
</span>
|
||||
<span ng-switch-when="delete">
|
||||
was deleted
|
||||
</div>
|
||||
<div class="history-datetime-small">{{ entry.time | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}</div>
|
||||
</td>
|
||||
<td ng-if="!entry.date_break" class="datetime-col hidden-sm hidden-xs">
|
||||
<div class="history-datetime">{{ entry.time | amDateFormat:'MMM Do YYYY, h:mm:ss a' }}</div>
|
||||
</td>
|
||||
<td class="revert-col hidden-xs hidden-sm">
|
||||
<div ng-if="!entry.date_break && repository.can_write" class="history-revert">
|
||||
<span ng-switch on="entry.action">
|
||||
<a ng-switch-when="create" ng-click="askRestoreTag(entry, true)" ng-if="!isCurrent(entry)">
|
||||
Restore <span class="tag-span"><span>{{ entry.tag_name }}</span></span> to
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.docker_image_id"
|
||||
manifest-digest="entry.manifest_digest"></span>
|
||||
</a>
|
||||
<a ng-switch-when="recreate" ng-click="askRestoreTag(entry, true)" ng-if="!isCurrent(entry)">
|
||||
Restore <span class="tag-span"><span>{{ entry.tag_name }}</span></span> to
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.docker_image_id"
|
||||
manifest-digest="entry.manifest_digest"></span>
|
||||
</a>
|
||||
<a ng-switch-when="delete" ng-click="askRestoreTag(entry, true)">
|
||||
Restore <span class="tag-span"><span>{{ entry.tag_name }}</span></span> to
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.docker_image_id"
|
||||
manifest-digest="entry.manifest_digest"></span>
|
||||
</a>
|
||||
<a ng-switch-when="move" ng-click="askRestoreTag(entry, false)" ng-if="!isCurrent(entry)">
|
||||
Restore <span class="tag-span" data-title="{{ entry.tag_name }}" bs-tooltip><span>{{ entry.tag_name }}</span></span> to
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.old_docker_image_id"
|
||||
manifest-digest="entry.old_manifest_digest"></span>
|
||||
</a>
|
||||
<a ng-switch-when="revert" ng-click="askRestoreTag(entry, false)" ng-if="!isCurrent(entry)">
|
||||
Restore <span class="tag-span" data-title="{{ entry.tag_name }}" bs-tooltip><span>{{ entry.tag_name }}</span></span> to
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="entry.old_docker_image_id"
|
||||
manifest-digest="entry.old_manifest_digest"></span>
|
||||
</a>
|
||||
</span>
|
||||
<span ng-switch-when="move">
|
||||
was moved to image
|
||||
<span class="image-link" repository="repository" image-id="entry.docker_image_id"></span>
|
||||
from image
|
||||
<span class="image-link" repository="repository" image-id="entry.old_docker_image_id"></span>
|
||||
</span>
|
||||
<span ng-switch-when="revert">
|
||||
was reverted to image
|
||||
<span class="image-link" repository="repository" image-id="entry.docker_image_id"></span>
|
||||
from image
|
||||
<span class="image-link" repository="repository" image-id="entry.old_docker_image_id"></span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="history-datetime">{{ entry.time | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="tag-operations-dialog" repository="repository"
|
||||
image-loader="imageLoader"
|
||||
action-handler="tagActionHandler"></div>
|
||||
|
||||
</div>
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
<div class="repo-panel-changes-element">
|
||||
<div class="cor-loader" ng-show="loading"></div>
|
||||
<div ng-show="!loading">
|
||||
<h3 class="tab-header">
|
||||
Visualize Tags:
|
||||
<span class="multiselect-dropdown" items="tagNames" selected-items="selectedTagsSlice"
|
||||
item-name="tag" item-checked="updateState()">
|
||||
<span class="tag-span">{{ item }}</span>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<!-- No Tags Selected -->
|
||||
<div class="empty" ng-if="!selectedTagsSlice.length">
|
||||
<div class="empty-primary-msg">No tags selected to view</div>
|
||||
<div class="empty-secondary-msg">
|
||||
Please select one or more tags above.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags Selected -->
|
||||
<div ng-show="selectedTagsSlice.length > 0">
|
||||
<!-- Tree View container -->
|
||||
<div class="col-md-8">
|
||||
<div class="panel panel-default">
|
||||
<!-- Image history tree -->
|
||||
<div id="image-history-container" onresize="tree.notifyResized()"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Side Panel -->
|
||||
<div class="col-md-4">
|
||||
<div class="side-panel-title" ng-if="currentTag">
|
||||
<i class="fa fa-tag"></i>{{ currentTag }}
|
||||
</div>
|
||||
<div class="side-panel-title" ng-if="currentImage">
|
||||
<i class="fa fa-archive"></i>{{ currentImage.substr(0, 12) }}
|
||||
</div>
|
||||
|
||||
<div class="side-panel">
|
||||
<!-- Tag Info -->
|
||||
<div class="tag-info-sidebar"
|
||||
tracker="tracker"
|
||||
tag="currentTag"
|
||||
image-selected="setImage(image)"
|
||||
delete-tag-requested="tagActionHandler.askDeleteTag(tag)"
|
||||
ng-if="currentTag">
|
||||
</div>
|
||||
|
||||
<!-- Image Info -->
|
||||
<div class="image-info-sidebar"
|
||||
tracker="tracker"
|
||||
image="currentImage"
|
||||
image-loader="imageLoader"
|
||||
tag-selected="setTag(tag)"
|
||||
add-tag-requested="tagActionHandler.askAddTag(image)"
|
||||
ng-if="currentImage">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tag-operations-dialog" repository="repository" image-loader="imageLoader"
|
||||
action-handler="tagActionHandler" tag-changed="handleTagChanged(data)"></div>
|
|
@ -1,129 +1,132 @@
|
|||
<div class="repo-panel-tags-element">
|
||||
<div class="tab-header-controls">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn" ng-class="!showingHistory ? 'btn-primary active' : 'btn-default'" ng-click="showHistory(false)">
|
||||
<i class="fa fa-tags"></i>Current Tags
|
||||
<button class="btn" ng-class="!expandedView ? 'btn-primary active' : 'btn-default'"
|
||||
ng-click="setExpanded(false)">
|
||||
Compact
|
||||
</button>
|
||||
<button class="btn" ng-class="showingHistory ? 'btn-info active' : 'btn-default'" ng-click="showHistory(true)">
|
||||
<i class="fa fa-history"></i>History
|
||||
<button class="btn" ng-class="expandedView ? 'btn-info active' : 'btn-default'"
|
||||
ng-click="setExpanded(true)">
|
||||
Expanded
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="tab-header"><span class="hidden-xs">Repository </span>Tags</h3>
|
||||
<div class="co-alert co-alert-danger" ng-if="hasDefcon1">
|
||||
One or more of your tags has an <strong>extremely critical</strong> vulnerability which should be addressed immediately:
|
||||
<a href="{{ vuln.Link }}" ng-repeat="(key, vuln) in defcon1" style="margin-left: 10px;" ng-safenewtab>
|
||||
{{ vuln.Name }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- History view -->
|
||||
<div class="repo-tag-history" repository="repository" filter="options.historyFilter"
|
||||
is-enabled="showingHistory" ng-show="showingHistory"></div>
|
||||
<div class="co-check-bar">
|
||||
<span class="cor-checkable-menu" controller="checkedTags">
|
||||
<div class="cor-checkable-menu-item" item-filter="allTagFilter(item)">
|
||||
<i class="fa fa-check-square-o"></i>All Tags
|
||||
</div>
|
||||
<div class="cor-checkable-menu-item" item-filter="noTagFilter(item)">
|
||||
<i class="fa fa-square-o"></i>No Tags
|
||||
</div>
|
||||
<div class="cor-checkable-menu-item" item-filter="commitTagFilter(item)">
|
||||
<i class="fa fa-git"></i>Commit SHAs
|
||||
</div>
|
||||
|
||||
<!-- Normal View -->
|
||||
<div ng-show="!showingHistory">
|
||||
<div class="co-alert co-alert-danger" ng-if="hasDefcon1">
|
||||
One or more of your tags has an <strong>extremely critical</strong> vulnerability which should be addressed immediately:
|
||||
<a href="{{ vuln.Link }}" ng-repeat="(key, vuln) in defcon1" style="margin-left: 10px;" ng-safenewtab>
|
||||
{{ vuln.Name }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="cor-checkable-menu-item" item-filter="imageIDFilter(it.image_id, item)"
|
||||
ng-repeat="it in imageTrackEntries">
|
||||
<i class="fa fa-circle-o" ng-style="{'color': it.color}"></i> {{ it.image_id.substr(0, 12) }}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div class="co-check-bar">
|
||||
<span class="cor-checkable-menu" controller="checkedTags">
|
||||
<div class="cor-checkable-menu-item" item-filter="allTagFilter(item)">
|
||||
<i class="fa fa-check-square-o"></i>All Tags
|
||||
</div>
|
||||
<div class="cor-checkable-menu-item" item-filter="noTagFilter(item)">
|
||||
<i class="fa fa-square-o"></i>No Tags
|
||||
</div>
|
||||
<div class="cor-checkable-menu-item" item-filter="commitTagFilter(item)">
|
||||
<i class="fa fa-git"></i>Commit SHAs
|
||||
</div>
|
||||
<span class="co-checked-actions" ng-if="checkedTags.checked.length">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
|
||||
<i class="fa fa-cog"></i>
|
||||
Actions
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li>
|
||||
<a ng-click="showHistory(checkedTags.checked)">
|
||||
<i class="fa fa-history"></i><span class="text">View Tags History</span>
|
||||
</a>
|
||||
</li>
|
||||
<li ng-if="repository.can_write">
|
||||
<a ng-click="askDeleteMultipleTags(checkedTags.checked)">
|
||||
<i class="fa fa-times"></i><span class="text">Delete Tags</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div class="cor-checkable-menu-item" item-filter="imageIDFilter(it.image_id, item)"
|
||||
ng-repeat="it in imageTrackEntries">
|
||||
<i class="fa fa-circle-o" ng-style="{'color': it.color}"></i> {{ it.image_id.substr(0, 12) }}
|
||||
</div>
|
||||
</span>
|
||||
<span class="co-filter-box">
|
||||
<span class="page-controls" total-count="tags.length" current-page="options.page" page-size="tagsPerPage"></span>
|
||||
<input class="form-control" type="text" ng-model="options.filter" placeholder="Filter Tags...">
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="co-checked-actions" ng-if="checkedTags.checked.length">
|
||||
<a class="btn btn-default" ng-click="setTab('changes')">
|
||||
<i class="fa fa-code-fork"></i><span class="text">Visualize</span>
|
||||
</a>
|
||||
<a class="btn btn-default"
|
||||
ng-click="showHistory(true, getTagNames(checkedTags.checked))"
|
||||
ng-if="repository.can_write">
|
||||
<i class="fa fa-history"></i><span class="text">View History</span>
|
||||
</a>
|
||||
<button class="btn btn-primary"
|
||||
ng-click="askDeleteMultipleTags(checkedTags.checked)"
|
||||
ng-if="repository.can_write">
|
||||
<i class="fa fa-times"></i><span class="text">Delete</span>
|
||||
</button>
|
||||
</span>
|
||||
<div class="co-alert co-alert-info" ng-if="allTagsSelected && !fullPageSelected">
|
||||
All <strong>{{ tags.length }}</strong> visible tags are selected.
|
||||
<a ng-click="clearSelectedTags()">Clear selection</a>.
|
||||
</div>
|
||||
|
||||
<span class="co-filter-box">
|
||||
<span class="page-controls" total-count="tags.length" current-page="options.page" page-size="tagsPerPage"></span>
|
||||
<input class="form-control" type="text" ng-model="options.filter" placeholder="Filter Tags...">
|
||||
</span>
|
||||
</div>
|
||||
<div class="co-alert co-alert-info" ng-if="fullPageSelected">
|
||||
All <strong>{{ tagsPerPage }}</strong> tags on this page are selected.
|
||||
<a ng-click="selectAllTags()">Select all {{ tags.length }} tags currently visible</a>.
|
||||
</div>
|
||||
|
||||
<div class="co-alert co-alert-info" ng-if="allTagsSelected && !fullPageSelected">
|
||||
All <strong>{{ tags.length }}</strong> visible tags are selected.
|
||||
<a ng-click="clearSelectedTags()">Clear selection</a>.
|
||||
</div>
|
||||
<div class="cor-loader" ng-show="!isEnabled"></div>
|
||||
|
||||
<div class="co-alert co-alert-info" ng-if="fullPageSelected">
|
||||
All <strong>{{ tagsPerPage }}</strong> tags on this page are selected.
|
||||
<a ng-click="selectAllTags()">Select all {{ tags.length }} tags currently visible</a>.
|
||||
</div>
|
||||
<table class="co-table co-fixed-table" id="tagsTable" ng-if="isEnabled" style="margin-top: 20px;">
|
||||
<thead>
|
||||
<td class="checkbox-col"></td>
|
||||
<td ng-class="tablePredicateClass('name', options.predicate, options.reverse)">
|
||||
<a ng-click="orderBy('name')">Tag</a>
|
||||
</td>
|
||||
<td class="hidden-xs"
|
||||
ng-class="tablePredicateClass('last_modified_datetime', options.predicate, options.reverse)"
|
||||
style="width: 140px;">
|
||||
<a ng-click="orderBy('last_modified_datetime')">Last Modified</a>
|
||||
</td>
|
||||
<td class="hidden-xs"
|
||||
ng-class="tablePredicateClass('security_scanned', options.predicate, options.reverse)"
|
||||
style="width: 180px;"
|
||||
quay-require="['SECURITY_SCANNER']">
|
||||
Security Scan
|
||||
</td>
|
||||
<td class="hidden-sm hidden-xs"
|
||||
ng-class="tablePredicateClass('size', options.predicate, options.reverse)"
|
||||
style="width: 80px;">
|
||||
<a ng-click="orderBy('size')" data-title="The compressed size of the tag's image" data-container="body" bs-tooltip>Size</a>
|
||||
</td>
|
||||
<td class="hidden-xs hidden-sm"
|
||||
ng-class="tablePredicateClass('image_id', options.predicate, options.reverse)"
|
||||
style="width: 140px;">
|
||||
<a ng-click="orderBy('image_id')">Image</a>
|
||||
</td>
|
||||
<td class="hidden-xs hidden-sm image-track" ng-repeat="it in imageTracks"
|
||||
ng-if="imageTracks.length <= maxTrackCount"></td>
|
||||
<td class="hidden-xs hidden-sm" ng-if="imageTracks.length > maxTrackCount"
|
||||
style="width: 20px; position: relative;">
|
||||
<span class="image-track-dot" style="border-color: #ccc; top: 4px;"></span>
|
||||
</td>
|
||||
<td class="options-col"></td>
|
||||
<td class="options-col"></td>
|
||||
<td class="hidden-xs hidden-sm" style="width: 4px"></td>
|
||||
</thead>
|
||||
|
||||
<div class="cor-loader" ng-show="!isEnabled"></div>
|
||||
|
||||
<table class="co-table co-fixed-table" id="tagsTable" ng-if="isEnabled" style="margin-top: 20px;">
|
||||
<thead>
|
||||
<td class="checkbox-col"></td>
|
||||
<td ng-class="tablePredicateClass('name', options.predicate, options.reverse)">
|
||||
<a ng-click="orderBy('name')">Tag</a>
|
||||
</td>
|
||||
<td class="hidden-xs"
|
||||
ng-class="tablePredicateClass('last_modified_datetime', options.predicate, options.reverse)"
|
||||
style="width: 140px;">
|
||||
<a ng-click="orderBy('last_modified_datetime')">Last Modified</a>
|
||||
</td>
|
||||
<td class="hidden-xs"
|
||||
ng-class="tablePredicateClass('security_scanned', options.predicate, options.reverse)"
|
||||
style="width: 270px;"
|
||||
quay-require="['SECURITY_SCANNER']">
|
||||
Security Scan
|
||||
</td>
|
||||
<td class="hidden-sm hidden-xs"
|
||||
ng-class="tablePredicateClass('size', options.predicate, options.reverse)"
|
||||
style="width: 80px;">
|
||||
<a ng-click="orderBy('size')" data-title="The compressed size of the tag's image" data-container="body" bs-tooltip>Size</a>
|
||||
</td>
|
||||
<td class="hidden-xs hidden-sm"
|
||||
ng-class="tablePredicateClass('image_id', options.predicate, options.reverse)"
|
||||
style="width: 120px;">
|
||||
<a ng-click="orderBy('image_id')">Image</a>
|
||||
</td>
|
||||
<td class="hidden-xs hidden-sm image-track" ng-repeat="it in imageTracks"
|
||||
ng-if="imageTracks.length <= maxTrackCount"></td>
|
||||
<td class="hidden-xs hidden-sm" ng-if="imageTracks.length > maxTrackCount"
|
||||
style="width: 20px; position: relative;">
|
||||
<span class="image-track-dot" style="border-color: #ccc; top: 4px;"></span>
|
||||
</td>
|
||||
<td class="options-col"></td>
|
||||
<td class="options-col"></td>
|
||||
<td class="options-col"></td>
|
||||
<td class="hidden-xs hidden-sm" style="width: 4px"></td>
|
||||
</thead>
|
||||
|
||||
<tr class="co-checkable-row"
|
||||
ng-repeat="tag in tags | slice:(tagsPerPage * options.page):(tagsPerPage * (options.page + 1))"
|
||||
ng-class="checkedTags.isChecked(tag, checkedTags.checked) ? 'checked' : ''"
|
||||
bindonce>
|
||||
<tbody class="co-checkable-row"
|
||||
ng-repeat="tag in tags | slice:(tagsPerPage * options.page):(tagsPerPage * (options.page + 1))"
|
||||
ng-class="checkedTags.isChecked(tag, checkedTags.checked) ? 'checked' : ''"
|
||||
bindonce>
|
||||
<tr ng-class="expandedView ? 'expanded-view': ''">
|
||||
<td><span class="cor-checkable-item" controller="checkedTags" item="tag"></span></td>
|
||||
<td class="co-flowing-col"><span class="tag-span"><i class="fa fa-tag"></i><span bo-text="tag.name"></span></span></td>
|
||||
<td class="co-flowing-col"><span class="tag-span"><span bo-text="tag.name"></span></span></td>
|
||||
<td class="hidden-xs">
|
||||
<span am-time-ago="tag.last_modified" bo-if="tag.last_modified"></span>
|
||||
<span bo-if="tag.last_modified" data-title="{{ tag.last_modified | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}" bs-tooltip>
|
||||
<span am-time-ago="tag.last_modified"></span>
|
||||
</span>
|
||||
<span bo-if="!tag.last_modified">Unknown</span>
|
||||
</td>
|
||||
<td quay-require="['SECURITY_SCANNER']" class="security-scan-col hidden-xs">
|
||||
|
@ -141,7 +144,7 @@
|
|||
data-title="The image for this tag is queued to be scanned for vulnerabilities"
|
||||
bs-tooltip>
|
||||
<i class="fa fa-ellipsis-h"></i>
|
||||
Queued for scan
|
||||
Queued
|
||||
</span>
|
||||
|
||||
<!-- Scan Failed -->
|
||||
|
@ -149,7 +152,7 @@
|
|||
data-title="The image for this tag could not be scanned for vulnerabilities"
|
||||
bs-tooltip>
|
||||
<i class="fa fa-question-circle"></i>
|
||||
Unable to scan image
|
||||
Unable to scan
|
||||
</span>
|
||||
|
||||
<!-- No Features -->
|
||||
|
@ -178,29 +181,27 @@
|
|||
<span ng-if="getTagVulnerabilities(tag).status == 'scanned' && getTagVulnerabilities(tag).hasFeatures && getTagVulnerabilities(tag).hasVulnerabilities"
|
||||
ng-class="getTagVulnerabilities(tag).highestVulnerability.Priority"
|
||||
class="has-vulns" bindonce>
|
||||
<a class="vuln-link" bo-href-i="/repository/{{ repository.namespace }}/{{ repository.name }}/image/{{ tag.image_id }}?tab=vulnerabilities"
|
||||
data-title="The image for this tag has {{ getTagVulnerabilities(tag).highestVulnerability.Count }} {{ getTagVulnerabilities(tag).highestVulnerability.Priority }} level vulnerabilities"
|
||||
bs-tooltip>
|
||||
<span class="highest-vuln">
|
||||
<span class="vulnerability-priority-view" priority="getTagVulnerabilities(tag).highestVulnerability.Priority">
|
||||
{{ getTagVulnerabilities(tag).highestVulnerability.Count }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span ng-if="getTagVulnerabilities(tag).vulnerabilities.length - getTagVulnerabilities(tag).highestVulnerability.Count > 0"
|
||||
class="other-vulns">
|
||||
+ {{ getTagVulnerabilities(tag).vulnerabilities.length - getTagVulnerabilities(tag).highestVulnerability.Count }} others
|
||||
</span>
|
||||
</a>
|
||||
<a bo-href-i="/repository/{{ repository.namespace }}/{{ repository.name }}/image/{{ tag.image_id }}?tab=vulnerabilities" style="display: inline-block; margin-left: 6px;">
|
||||
More Info
|
||||
<a class="vuln-link" bo-href-i="/repository/{{ repository.namespace }}/{{ repository.name }}/image/{{ tag.image_id }}?tab=vulnerabilities"
|
||||
data-title="This tag has {{ getTagVulnerabilities(tag).vulnerabilities.length }} vulnerabilities across {{ getTagVulnerabilities(tag).featuresInfo.brokenFeaturesCount }} packages"
|
||||
bs-tooltip>
|
||||
<!-- Donut -->
|
||||
<span class="donut-chart" width="24" data="getTagVulnerabilities(tag).featuresInfo.severityBreakdown"></span>
|
||||
|
||||
<!-- Messaging -->
|
||||
<span ng-if="getTagVulnerabilities(tag).featuresInfo.fixableFeatureCount == 0">
|
||||
{{ getTagVulnerabilities(tag).featuresInfo.brokenFeaturesCount }} vulnerable package<span ng-if="getTagVulnerabilities(tag).featuresInfo.brokenFeaturesCount != 1">s</span>
|
||||
</span>
|
||||
<span ng-if="getTagVulnerabilities(tag).featuresInfo.fixableFeatureCount > 0">
|
||||
{{ getTagVulnerabilities(tag).featuresInfo.fixableFeatureCount }} fixable package<span ng-if="getTagVulnerabilities(tag).featuresInfo.fixableFeatureCount != 1">s</span>
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="hidden-sm hidden-xs" bo-text="tag.size | bytes"></td>
|
||||
<td class="hidden-xs hidden-sm image-id-col">
|
||||
<span class="image-link" repository="repository" image-id="tag.image_id"></span>
|
||||
<span class="image-link" repository="repository" image-id="tag.image_id" manifest-digest="tag.manifest_digest"></span>
|
||||
</td>
|
||||
<td class="hidden-xs hidden-sm image-track"
|
||||
ng-if="imageTracks.length > maxTrackCount" bindonce>
|
||||
|
@ -225,34 +226,16 @@
|
|||
ng-click="fetchTagActionHandler.askFetchTag(tag)">
|
||||
</i>
|
||||
</td>
|
||||
<td class="options-col">
|
||||
<div class="dropdown" style="text-align: left;">
|
||||
<i class="fa fa-history dropdown-toggle" data-toggle="dropdown" data-title="Tag History"
|
||||
ng-click="loadTagHistory(tag)"
|
||||
bs-tooltip></i>
|
||||
<ul class="dropdown-menu pull-right">
|
||||
<li ng-if="!tagHistory[tag.name]"><div class="cor-loader"></div></li>
|
||||
<li class="tag-image-history-item" ng-repeat="entry in tagHistory[tag.name]" bindonce>
|
||||
<a ng-click="askRevertTag(tag, entry.docker_image_id)">
|
||||
<div class="image-id">
|
||||
<i class="fa fa-circle-o"
|
||||
bo-style="{'color': imageMap[entry.docker_image_id].color || '#eee'}"></i>
|
||||
{{ entry.docker_image_id.substr(0, 12) }}
|
||||
</div>
|
||||
<div class="image-apply-time">
|
||||
{{ entry.start_ts * 1000 | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
<td class="options-col">
|
||||
<span bo-if="repository.can_write">
|
||||
<span class="cor-options-menu">
|
||||
<span class="cor-option" option-click="askAddTag(tag)">
|
||||
<i class="fa fa-plus"></i> Add New Tag
|
||||
</span>
|
||||
<span class="cor-option" option-click="showLabelEditor(tag)"
|
||||
ng-if="tag.manifest_digest">
|
||||
<i class="fa fa-tags"></i> Edit Labels
|
||||
</span>
|
||||
<span class="cor-option" option-click="askDeleteTag(tag.name)">
|
||||
<i class="fa fa-times"></i> Delete Tag
|
||||
</span>
|
||||
|
@ -260,23 +243,40 @@
|
|||
</span>
|
||||
</td>
|
||||
<td class="options-col hidden-xs hidden-sm"><!-- Whitespace col --></td>
|
||||
</tr>
|
||||
</table>
|
||||
</tr>
|
||||
<tr ng-if="expandedView">
|
||||
<td class="checkbox-col"></td>
|
||||
<td class="labels-col" colspan="{{5 + (Features.SECURITY_SCANNER ? 1 : 0)}}">
|
||||
<div class="manifest-label-list" repository="repository"
|
||||
manifest-digest="tag.manifest_digest" cache="labelCache"></div>
|
||||
</td>
|
||||
<td class="hidden-xs hidden-sm image-track" ng-repeat="it in imageTracks"
|
||||
ng-if="imageTracks.length <= maxTrackCount" bindonce>
|
||||
<span ng-repeat="entry in it.entries">
|
||||
<span class="image-track-line"
|
||||
bo-class="trackLineExpandedClass($parent.$parent.$parent.$index, entry)"
|
||||
bo-style="{'borderColor': entry.color}"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="empty" ng-if="allTags.length && !tags.length">
|
||||
<div class="empty-primary-msg">No matching tags found.</div>
|
||||
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
|
||||
</div>
|
||||
<div class="empty" ng-if="allTags.length && !tags.length">
|
||||
<div class="empty-primary-msg">No matching tags found.</div>
|
||||
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
|
||||
</div>
|
||||
|
||||
<div class="empty" ng-if="!allTags.length">
|
||||
<div class="empty-primary-msg">This repository is empty.</div>
|
||||
<div class="empty-secondary-msg">Push a tag or initiate a build to populate this repository.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty" ng-if="!allTags.length">
|
||||
<div class="empty-primary-msg">This repository is empty.</div>
|
||||
<div class="empty-secondary-msg">Push a tag or initiate a build to populate this repository.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tag-operations-dialog" repository="repository"
|
||||
image-loader="imageLoader"
|
||||
action-handler="tagActionHandler"></div>
|
||||
action-handler="tagActionHandler"
|
||||
labels-changed="handleLabelsChanged(manifest_digest)"></div>
|
||||
|
||||
<div class="fetch-tag-dialog" repository="repository" action-handler="fetchTagActionHandler"></div>
|
||||
|
|
|
@ -18,6 +18,27 @@
|
|||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
|
||||
<!-- Edit Labels Dialog -->
|
||||
<div class="cor-confirm-dialog"
|
||||
dialog-context="editLabelsInfo"
|
||||
dialog-action="editLabels(info, callback)"
|
||||
dialog-title="Edit Manifest Labels"
|
||||
dialog-action-title="Edit Labels">
|
||||
|
||||
<div class="cor-loader" ng-if="editLabelsInfo.loading"></div>
|
||||
<div ng-if="!editLabelsInfo.loading && editLabelsInfo.labels">
|
||||
<div><strong>Read-only labels:</strong></div>
|
||||
<div class="label-section">
|
||||
<div class="label-list" labels="editLabelsInfo.readonly_labels"></div>
|
||||
</div>
|
||||
|
||||
<div><strong>Mutable labels:</strong></div>
|
||||
<div class="label-section">
|
||||
<div class="label-input" labels="editLabelsInfo.mutable_labels"
|
||||
updated-labels="editLabelsInfo.updated_labels"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Tag Dialog -->
|
||||
<div class="modal fade" id="createOrMoveTagModal">
|
||||
|
@ -106,19 +127,21 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recert Tag Confirm -->
|
||||
<!-- Restore Tag Confirm -->
|
||||
<div class="cor-confirm-dialog"
|
||||
dialog-context="revertTagInfo"
|
||||
dialog-action="revertTag(info.tag, info.image_id, callback)"
|
||||
dialog-title="Revert Tag"
|
||||
dialog-action-title="Revert Tag">
|
||||
dialog-context="restoreTagInfo"
|
||||
dialog-action="restoreTag(info.tag, info.image_id, info.manifest_digest, callback)"
|
||||
dialog-title="Restore Tag"
|
||||
dialog-action-title="Restore Tag">
|
||||
|
||||
<div class="co-alert co-alert-warning">
|
||||
This will change the image to which the tag points.
|
||||
</div>
|
||||
|
||||
Are you sure you want to revert tag
|
||||
<span class="label label-default tag">{{ revertTagInfo.tag.name }}</span> to image
|
||||
<span class="image-id">{{ revertTagInfo.image_id.substr(0, 12) }}?</span>
|
||||
Are you sure you want to restore tag
|
||||
<span class="label label-default tag">{{ restoreTagInfo.tag.name }}</span> to image
|
||||
<span class="image-link" repository="repository"
|
||||
image-id="restoreTagInfo.image_id"
|
||||
manifest-digest="restoreTagInfo.manifest_digest"></span>?
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,218 +0,0 @@
|
|||
/**
|
||||
* An element which displays the changes visualization panel for a repository view.
|
||||
*/
|
||||
angular.module('quay').directive('repoPanelChanges', function () {
|
||||
var RepositoryImageTracker = function(repository, imageLoader) {
|
||||
this.repository = repository;
|
||||
this.imageLoader = imageLoader;
|
||||
|
||||
// Build a map of image ID -> image.
|
||||
var images = imageLoader.images;
|
||||
var imageIDMap = {};
|
||||
|
||||
images.forEach(function(image) {
|
||||
imageIDMap[image.id] = image;
|
||||
});
|
||||
|
||||
this.imageMap_ = imageIDMap;
|
||||
};
|
||||
|
||||
RepositoryImageTracker.prototype.imageLink = function(image) {
|
||||
return '/repository/' + this.repository.namespace + '/' +
|
||||
this.repository.name + '/image/' + image;
|
||||
};
|
||||
|
||||
RepositoryImageTracker.prototype.getImageForTag = function(tag) {
|
||||
var tagData = this.lookupTag(tag);
|
||||
if (!tagData) { return null; }
|
||||
|
||||
return this.imageMap_[tagData.image_id];
|
||||
};
|
||||
|
||||
RepositoryImageTracker.prototype.lookupTag = function(tag) {
|
||||
return this.repository.tags[tag];
|
||||
};
|
||||
|
||||
RepositoryImageTracker.prototype.lookupImage = function(image) {
|
||||
return this.imageMap_[image];
|
||||
};
|
||||
|
||||
RepositoryImageTracker.prototype.forAllTagImages = function(tag, callback) {
|
||||
var tagData = this.lookupTag(tag);
|
||||
if (!tagData) { return; }
|
||||
|
||||
var tagImage = this.imageMap_[tagData.image_id];
|
||||
if (!tagImage) { return; }
|
||||
|
||||
// Callback the tag's image itself.
|
||||
callback(tagImage);
|
||||
|
||||
// Callback any parent images.
|
||||
if (!tagImage.ancestors) { return; }
|
||||
|
||||
var ancestors = tagImage.ancestors.split('/');
|
||||
for (var i = 0; i < ancestors.length; ++i) {
|
||||
var image = this.imageMap_[ancestors[i]];
|
||||
if (image) {
|
||||
callback(image);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
RepositoryImageTracker.prototype.getTotalSize = function(tag) {
|
||||
var size = 0;
|
||||
this.forAllTagImages(tag, function(image) {
|
||||
size += image.size;
|
||||
});
|
||||
return size;
|
||||
};
|
||||
|
||||
RepositoryImageTracker.prototype.getImagesForTagBySize = function(tag) {
|
||||
var images = [];
|
||||
this.forAllTagImages(tag, function(image) {
|
||||
images.push(image);
|
||||
});
|
||||
|
||||
images.sort(function(a, b) {
|
||||
return b.size - a.size;
|
||||
});
|
||||
|
||||
return images;
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/repo-view/repo-panel-changes.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'selectedTags': '=selectedTags',
|
||||
|
||||
'imagesResource': '=imagesResource',
|
||||
'imageLoader': '=imageLoader',
|
||||
|
||||
'isEnabled': '=isEnabled'
|
||||
},
|
||||
controller: function($scope, $element, $timeout, ApiService, UtilService, ImageMetadataService) {
|
||||
$scope.tagNames = [];
|
||||
$scope.loading = true;
|
||||
|
||||
$scope.$watch('selectedTags', function(selectedTags) {
|
||||
if (!selectedTags) { return; }
|
||||
$scope.selectedTagsSlice = selectedTags.slice(0, 10);
|
||||
});
|
||||
|
||||
var update = function() {
|
||||
if (!$scope.repository || !$scope.isEnabled) { return; }
|
||||
|
||||
$scope.tagNames = Object.keys($scope.repository.tags);
|
||||
$scope.currentImage = null;
|
||||
$scope.currentTag = null;
|
||||
|
||||
$scope.loading = true;
|
||||
$scope.imageLoader.loadImages($scope.selectedTagsSlice, function() {
|
||||
$scope.loading = false;
|
||||
updateImages();
|
||||
});
|
||||
};
|
||||
|
||||
var updateImages = function() {
|
||||
if (!$scope.repository || !$scope.imageLoader || !$scope.isEnabled) { return; }
|
||||
|
||||
$scope.tracker = new RepositoryImageTracker($scope.repository, $scope.imageLoader);
|
||||
if ($scope.selectedTagsSlice && $scope.selectedTagsSlice.length) {
|
||||
refreshTree();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('selectedTagsSlice', update)
|
||||
$scope.$watch('repository', update);
|
||||
$scope.$watch('isEnabled', update);
|
||||
|
||||
$scope.updateState = function() {
|
||||
update();
|
||||
};
|
||||
|
||||
var refreshTree = function() {
|
||||
if (!$scope.repository || !$scope.imageLoader || !$scope.isEnabled) { return; }
|
||||
if ($scope.selectedTagsSlice.length < 1) { return; }
|
||||
|
||||
$('#image-history-container').empty();
|
||||
|
||||
var getTagsForImage = function(image) {
|
||||
return $scope.imageLoader.getTagsForImage(image);
|
||||
};
|
||||
|
||||
var tree = new ImageHistoryTree(
|
||||
$scope.repository.namespace,
|
||||
$scope.repository.name,
|
||||
$scope.imageLoader.images,
|
||||
getTagsForImage,
|
||||
UtilService.getFirstMarkdownLineAsText,
|
||||
$scope.getTimeSince,
|
||||
ImageMetadataService.getEscapedFormattedCommand,
|
||||
function(tag) {
|
||||
return $.inArray(tag, $scope.selectedTagsSlice) >= 0;
|
||||
});
|
||||
|
||||
$scope.tree = tree.draw('image-history-container');
|
||||
if ($scope.tree) {
|
||||
// Give enough time for the UI to be drawn before we resize the tree.
|
||||
$timeout(function() {
|
||||
$scope.tree.notifyResized();
|
||||
$scope.setTag($scope.selectedTagsSlice[0]);
|
||||
}, 100);
|
||||
|
||||
// Listen for changes to the selected tag and image in the tree.
|
||||
$($scope.tree).bind('tagChanged', function(e) {
|
||||
$scope.$apply(function() { $scope.setTag(e.tag); });
|
||||
});
|
||||
|
||||
$($scope.tree).bind('imageChanged', function(e) {
|
||||
$scope.$apply(function() { $scope.setImage(e.image.id); });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$scope.setImage = function(image_id) {
|
||||
$scope.currentTag = null;
|
||||
$scope.currentImage = image_id;
|
||||
$scope.tree.setImage(image_id);
|
||||
};
|
||||
|
||||
$scope.setTag = function(tag) {
|
||||
$scope.currentTag = tag;
|
||||
$scope.currentImage = null;
|
||||
$scope.tree.setTag(tag);
|
||||
};
|
||||
|
||||
$scope.parseDate = function(dateString) {
|
||||
return Date.parse(dateString);
|
||||
};
|
||||
|
||||
$scope.getTimeSince = function(createdTime) {
|
||||
return moment($scope.parseDate(createdTime)).fromNow();
|
||||
};
|
||||
|
||||
$scope.handleTagChanged = function(data) {
|
||||
data.removed.map(function(tag) {
|
||||
$scope.currentImage = null;
|
||||
$scope.currentTag = null;
|
||||
});
|
||||
|
||||
data.added.map(function(tag) {
|
||||
$scope.selectedTags.push(tag);
|
||||
$scope.currentTag = tag;
|
||||
});
|
||||
|
||||
update();
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ angular.module('quay').directive('repoPanelTags', function () {
|
|||
scope: {
|
||||
'repository': '=repository',
|
||||
'selectedTags': '=selectedTags',
|
||||
'historyFilter': '=historyFilter',
|
||||
'imagesResource': '=imagesResource',
|
||||
'imageLoader': '=imageLoader',
|
||||
|
||||
|
@ -31,11 +32,12 @@ angular.module('quay').directive('repoPanelTags', function () {
|
|||
};
|
||||
|
||||
$scope.iterationState = {};
|
||||
$scope.tagHistory = {};
|
||||
$scope.tagActionHandler = null;
|
||||
$scope.showingHistory = false;
|
||||
$scope.tagsPerPage = 25;
|
||||
|
||||
$scope.expandedView = false;
|
||||
$scope.labelCache = {};
|
||||
|
||||
$scope.imageVulnerabilities = {};
|
||||
$scope.defcon1 = {};
|
||||
$scope.hasDefcon1 = false;
|
||||
|
@ -213,7 +215,8 @@ angular.module('quay').directive('repoPanelTags', function () {
|
|||
highest = {
|
||||
'Priority': vuln.Severity,
|
||||
'Count': 1,
|
||||
'index': VulnerabilityService.LEVELS[vuln.Severity].index
|
||||
'index': VulnerabilityService.LEVELS[vuln.Severity].index,
|
||||
'Color': VulnerabilityService.LEVELS[vuln.Severity].color
|
||||
}
|
||||
} else if (VulnerabilityService.LEVELS[vuln.Severity].index == highest.index) {
|
||||
highest['Count']++;
|
||||
|
@ -226,6 +229,7 @@ angular.module('quay').directive('repoPanelTags', function () {
|
|||
imageData.hasVulnerabilities = !!vulnerabilities.length;
|
||||
imageData.vulnerabilities = vulnerabilities;
|
||||
imageData.highestVulnerability = highest;
|
||||
imageData.featuresInfo = VulnerabilityService.buildFeaturesInfo(null, resp);
|
||||
}
|
||||
}, function() {
|
||||
imageData.loading = false;
|
||||
|
@ -261,13 +265,24 @@ angular.module('quay').directive('repoPanelTags', function () {
|
|||
$scope.checkedTags.setChecked($scope.tags);
|
||||
};
|
||||
|
||||
$scope.showHistory = function(value, opt_tagname) {
|
||||
$scope.options.historyFilter = opt_tagname ? opt_tagname : '';
|
||||
$scope.showingHistory = value;
|
||||
};
|
||||
$scope.trackLineExpandedClass = function(index, track_info) {
|
||||
var startIndex = $.inArray(track_info.tags[0], $scope.tags);
|
||||
var endIndex = $.inArray(track_info.tags[track_info.tags.length - 1], $scope.tags);
|
||||
index += $scope.options.page * $scope.tagsPerPage;
|
||||
|
||||
$scope.toggleHistory = function() {
|
||||
$scope.showHistory(!$scope.showingHistory);
|
||||
if (index < startIndex) {
|
||||
return 'before';
|
||||
}
|
||||
|
||||
if (index > endIndex) {
|
||||
return 'after';
|
||||
}
|
||||
|
||||
if (index >= startIndex && index < endIndex) {
|
||||
return 'middle';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
$scope.trackLineClass = function(index, track_info) {
|
||||
|
@ -321,6 +336,11 @@ angular.module('quay').directive('repoPanelTags', function () {
|
|||
$scope.tagActionHandler.askAddTag(tag.image_id);
|
||||
};
|
||||
|
||||
$scope.showLabelEditor = function(tag) {
|
||||
if (!tag.manifest_digest) { return; }
|
||||
$scope.tagActionHandler.showLabelEditor(tag.manifest_digest);
|
||||
};
|
||||
|
||||
$scope.orderBy = function(predicate) {
|
||||
if (predicate == $scope.options.predicate) {
|
||||
$scope.options.reverse = !$scope.options.reverse;
|
||||
|
@ -358,6 +378,19 @@ angular.module('quay').directive('repoPanelTags', function () {
|
|||
});
|
||||
};
|
||||
|
||||
$scope.showHistory = function(checked) {
|
||||
if (!checked.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.historyFilter = $scope.getTagNames(checked);
|
||||
$scope.setTab('history');
|
||||
};
|
||||
|
||||
$scope.setExpanded = function(expanded) {
|
||||
$scope.expandedView = expanded;
|
||||
};
|
||||
|
||||
$scope.getTagNames = function(checked) {
|
||||
var names = checked.map(function(tag) {
|
||||
return tag.name;
|
||||
|
@ -366,24 +399,8 @@ angular.module('quay').directive('repoPanelTags', function () {
|
|||
return names.join(',');
|
||||
};
|
||||
|
||||
$scope.loadTagHistory = function(tag) {
|
||||
delete $scope.tagHistory[tag.name];
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'specificTag': tag.name,
|
||||
'limit': 5
|
||||
};
|
||||
|
||||
ApiService.listRepoTags(null, params).then(function(resp) {
|
||||
$scope.tagHistory[tag.name] = resp.tags;
|
||||
}, ApiService.errorDisplay('Could not load tag history'));
|
||||
};
|
||||
|
||||
$scope.askRevertTag = function(tag, image_id) {
|
||||
if ($scope.repository.can_write) {
|
||||
$scope.tagActionHandler.askRevertTag(tag, image_id);
|
||||
}
|
||||
$scope.handleLabelsChanged = function(manifest_digest) {
|
||||
delete $scope.labelCache[manifest_digest];
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
71
static/js/directives/ui/donut-chart.js
Normal file
71
static/js/directives/ui/donut-chart.js
Normal file
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* An element which displays a donut chart of data.
|
||||
*/
|
||||
angular.module('quay').directive('donutChart', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/donut-chart.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'width': '@width',
|
||||
'data': '=data',
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
$scope.created = false;
|
||||
|
||||
// Based on: http://bl.ocks.org/erichoco/6694616
|
||||
var chart = d3.select($element.find('.donut-chart-element')[0]);
|
||||
|
||||
var renderChart = function() {
|
||||
if (!$scope.data || !$scope.data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $chart = $element.find('.donut-chart-element');
|
||||
$chart.empty();
|
||||
|
||||
var width = $scope.width * 1;
|
||||
var chart_m = width / 2 * 0.14;
|
||||
var chart_r = width / 2 * 0.85;
|
||||
|
||||
var topG = chart.append('svg:svg')
|
||||
.attr('width', (chart_r + chart_m) * 2)
|
||||
.attr('height', (chart_r + chart_m) * 2)
|
||||
.append('svg:g')
|
||||
.attr('class', 'donut')
|
||||
.attr('transform', 'translate(' + (chart_r + chart_m) + ',' +
|
||||
(chart_r + chart_m) + ')');
|
||||
|
||||
|
||||
var arc = d3.svg.arc()
|
||||
.innerRadius(chart_r * 0.6)
|
||||
.outerRadius(function(d, i) {
|
||||
return i == $scope.data.length - 1 ? chart_r * 1.2 : chart_r * 1;
|
||||
});
|
||||
|
||||
var pie = d3.layout.pie()
|
||||
.sort(null)
|
||||
.value(function(d) {
|
||||
return d.value;
|
||||
});
|
||||
|
||||
var reversed = $scope.data.slice(0).reverse();
|
||||
var g = topG.selectAll(".arc")
|
||||
.data(pie(reversed))
|
||||
.enter().append("g")
|
||||
.attr("class", "arc");
|
||||
|
||||
g.append("path")
|
||||
.attr("d", arc)
|
||||
.style('stroke', '#fff')
|
||||
.style("fill", function(d) { return d.data.color; });
|
||||
|
||||
};
|
||||
|
||||
$scope.$watch('data', renderChart);
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -10,9 +10,17 @@ angular.module('quay').directive('imageLink', function () {
|
|||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'imageId': '=imageId'
|
||||
'imageId': '=imageId',
|
||||
'manifestDigest': '=?manifestDigest'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
$scope.hasSHA256 = function(digest) {
|
||||
return digest && digest.indexOf('sha256:') == 0;
|
||||
};
|
||||
|
||||
$scope.getShortDigest = function(digest) {
|
||||
return digest.substr('sha256:'.length).substr(0, 12);
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
|
|
43
static/js/directives/ui/label-input.js
Normal file
43
static/js/directives/ui/label-input.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* An element which allows for editing labels.
|
||||
*/
|
||||
angular.module('quay').directive('labelInput', function () {
|
||||
return {
|
||||
templateUrl: '/static/directives/label-input.html',
|
||||
restrict: 'C',
|
||||
replace: true,
|
||||
scope: {
|
||||
'labels': '<labels',
|
||||
'updatedLabels': '=?updatedLabels',
|
||||
},
|
||||
controller: function($scope) {
|
||||
$scope.tags = [];
|
||||
|
||||
$scope.$watch('tags', function(tags) {
|
||||
if (!tags) { return; }
|
||||
$scope.updatedLabels = tags.filter(function(tag) {
|
||||
parts = tag['keyValue'].split('=', 2);
|
||||
return tag['label'] ? tag['label'] : {
|
||||
'key': parts[0],
|
||||
'value': parts[1],
|
||||
'is_new': true
|
||||
};
|
||||
});
|
||||
}, true);
|
||||
|
||||
$scope.$watch('labels', function(labels) {
|
||||
$scope.filteredLabels = labels.filter(function(label) {
|
||||
return label['source_type'] == 'api';
|
||||
});
|
||||
|
||||
$scope.tags = $scope.filteredLabels.map(function(label) {
|
||||
return {
|
||||
'id': label['id'],
|
||||
'keyValue': label['key'] + '=' + label['value'],
|
||||
'label': label
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
15
static/js/directives/ui/label-list.js
Normal file
15
static/js/directives/ui/label-list.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* An element which displays labels.
|
||||
*/
|
||||
angular.module('quay').directive('labelList', function () {
|
||||
return {
|
||||
templateUrl: '/static/directives/label-list.html',
|
||||
restrict: 'C',
|
||||
replace: true,
|
||||
scope: {
|
||||
expand: '@expand',
|
||||
labels: '<labels'
|
||||
},
|
||||
controller: function($scope) {}
|
||||
};
|
||||
});
|
32
static/js/directives/ui/label-view.js
Normal file
32
static/js/directives/ui/label-view.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* An element which displays a single label.
|
||||
*/
|
||||
angular.module('quay').directive('labelView', function () {
|
||||
return {
|
||||
templateUrl: '/static/directives/label-view.html',
|
||||
restrict: 'C',
|
||||
replace: true,
|
||||
scope: {
|
||||
expand: '@expand',
|
||||
label: '<label'
|
||||
},
|
||||
controller: function($scope, $sanitize) {
|
||||
$scope.getKind = function(label) {
|
||||
switch (label.media_type) {
|
||||
case 'application/json':
|
||||
return 'json';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
$scope.viewLabelValue = function() {
|
||||
bootbox.alert({
|
||||
size: "small",
|
||||
title: $scope.label.key,
|
||||
message: '<pre>' + $sanitize($scope.label.value) + '</pre>'
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
});
|
|
@ -125,7 +125,13 @@ angular.module('quay').directive('logsView', function () {
|
|||
return 'Remove permission for token {token} from repository {namespace}/{repo}';
|
||||
}
|
||||
},
|
||||
'revert_tag': 'Tag {tag} reverted to image {image} from image {original_image}',
|
||||
'revert_tag': function(metadata) {
|
||||
if (metadata.original_image) {
|
||||
return 'Tag {tag} restored to image {image} from image {original_image}';
|
||||
} else {
|
||||
return 'Tag {tag} recreated pointing to image {image}';
|
||||
}
|
||||
},
|
||||
'delete_tag': 'Tag {tag} deleted in repository {namespace}/{repo} by user {username}',
|
||||
'create_tag': 'Tag {tag} created in repository {namespace}/{repo} on image {image} by user {username}',
|
||||
'move_tag': 'Tag {tag} moved from image {original_image} to image {image} in repository {namespace}/{repo} by user {username}',
|
||||
|
@ -266,7 +272,7 @@ angular.module('quay').directive('logsView', function () {
|
|||
'delete_tag': 'Delete Tag',
|
||||
'create_tag': 'Create Tag',
|
||||
'move_tag': 'Move Tag',
|
||||
'revert_tag':' Revert Tag',
|
||||
'revert_tag':'Restore Tag',
|
||||
'org_create_team': 'Create team',
|
||||
'org_delete_team': 'Delete team',
|
||||
'org_add_team_member': 'Add team member',
|
||||
|
|
60
static/js/directives/ui/manifest-label-list.js
Normal file
60
static/js/directives/ui/manifest-label-list.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* An element which displays the labels on a repository manifest.
|
||||
*/
|
||||
angular.module('quay').directive('manifestLabelList', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/manifest-label-list.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'manifestDigest': '=manifestDigest',
|
||||
'cache': '=cache'
|
||||
},
|
||||
controller: function($scope, $element, ApiService) {
|
||||
$scope.labels = null;
|
||||
|
||||
var loadLabels = function() {
|
||||
if (!$scope.repository) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$scope.manifestDigest) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.cache[$scope.manifestDigest]) {
|
||||
$scope.labels = $scope.cache[$scope.manifestDigest];
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.labels = null;
|
||||
$scope.loadError = false;
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'manifestref': $scope.manifestDigest
|
||||
};
|
||||
|
||||
ApiService.listManifestLabels(null, params).then(function(resp) {
|
||||
$scope.labels = resp['labels'];
|
||||
$scope.cache[$scope.manifestDigest] = resp['labels'];
|
||||
}, function() {
|
||||
$scope.loadError = true;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.$watch('cache', function(cache) {
|
||||
if (cache && $scope.manifestDigest && $scope.labels && !cache[$scope.manifestDigest]) {
|
||||
loadLabels();
|
||||
}
|
||||
}, true);
|
||||
|
||||
$scope.$watch('repository', loadLabels);
|
||||
$scope.$watch('manifestDigest', loadLabels);
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -11,7 +11,8 @@ angular.module('quay').directive('repoTagHistory', function () {
|
|||
scope: {
|
||||
'repository': '=repository',
|
||||
'filter': '=filter',
|
||||
'isEnabled': '=isEnabled'
|
||||
'isEnabled': '=isEnabled',
|
||||
'imageLoader': '=imageLoader'
|
||||
},
|
||||
controller: function($scope, $element, ApiService) {
|
||||
$scope.tagHistoryData = null;
|
||||
|
@ -43,6 +44,7 @@ angular.module('quay').directive('repoTagHistory', function () {
|
|||
tags.forEach(function(tag) {
|
||||
var tagName = tag.name;
|
||||
var dockerImageId = tag.docker_image_id;
|
||||
var manifestDigest = tag.manifest_digest;
|
||||
|
||||
if (!tagEntries[tagName]) {
|
||||
tagEntries[tagName] = [];
|
||||
|
@ -53,8 +55,10 @@ angular.module('quay').directive('repoTagHistory', function () {
|
|||
tagEntries[entry.tag_name].splice(tagEntries[entry.tag_name].indexOf(entry), 1);
|
||||
};
|
||||
|
||||
var addEntry = function(action, time, opt_docker_id, opt_old_docker_id) {
|
||||
var addEntry = function(action, time, opt_docker_id, opt_old_docker_id,
|
||||
opt_manifest_digest, opt_old_manifest_digest) {
|
||||
var entry = {
|
||||
'tag': tag,
|
||||
'tag_name': tagName,
|
||||
'action': action,
|
||||
'start_ts': tag.start_ts,
|
||||
|
@ -62,7 +66,9 @@ angular.module('quay').directive('repoTagHistory', function () {
|
|||
'reversion': tag.reversion,
|
||||
'time': time * 1000, // JS expects ms, not s since epoch.
|
||||
'docker_image_id': opt_docker_id || dockerImageId,
|
||||
'old_docker_image_id': opt_old_docker_id || ''
|
||||
'old_docker_image_id': opt_old_docker_id || '',
|
||||
'manifest_digest': opt_manifest_digest || manifestDigest,
|
||||
'old_manifest_digest': opt_old_manifest_digest || null
|
||||
};
|
||||
|
||||
tagEntries[tagName].push(entry);
|
||||
|
@ -79,7 +85,8 @@ angular.module('quay').directive('repoTagHistory', function () {
|
|||
if (futureEntry.start_ts - tag.end_ts <= MOVE_THRESHOLD) {
|
||||
removeEntry(futureEntry);
|
||||
addEntry(futureEntry.reversion ? 'revert': 'move', tag.end_ts,
|
||||
futureEntry.docker_image_id, dockerImageId);
|
||||
futureEntry.docker_image_id, dockerImageId, futureEntry.manifest_digest,
|
||||
manifestDigest);
|
||||
} else {
|
||||
addEntry('delete', tag.end_ts)
|
||||
}
|
||||
|
@ -87,7 +94,7 @@ angular.module('quay').directive('repoTagHistory', function () {
|
|||
|
||||
// If the tag has a start time, it was created.
|
||||
if (tag.start_ts) {
|
||||
addEntry('create', tag.start_ts);
|
||||
addEntry(tag.reversion ? 'recreate' : 'create', tag.start_ts);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -130,6 +137,18 @@ angular.module('quay').directive('repoTagHistory', function () {
|
|||
$scope.historyEntryMap = tagEntries;
|
||||
};
|
||||
|
||||
$scope.isCurrent = function(entry) {
|
||||
return $scope.historyEntryMap[entry.tag_name][0] == entry;
|
||||
};
|
||||
|
||||
$scope.askRestoreTag = function(entity, use_current_id) {
|
||||
if ($scope.repository.can_write) {
|
||||
var docker_id = use_current_id ? entity.docker_image_id : entity.old_docker_image_id;
|
||||
var digest = use_current_id ? entity.manifest_digest : entity.old_manifest_digest;
|
||||
$scope.tagActionHandler.askRestoreTag(entity.tag, docker_id, digest);
|
||||
}
|
||||
};
|
||||
|
||||
$scope.getEntryClasses = function(entry, historyFilter) {
|
||||
if (!entry.action) { return ''; }
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
|||
'repository': '=repository',
|
||||
'actionHandler': '=actionHandler',
|
||||
'imageLoader': '=imageLoader',
|
||||
'tagChanged': '&tagChanged'
|
||||
'tagChanged': '&tagChanged',
|
||||
'labelsChanged': '&labelsChanged'
|
||||
},
|
||||
controller: function($scope, $element, $timeout, ApiService) {
|
||||
$scope.addingTag = false;
|
||||
|
@ -115,7 +116,7 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
|||
}, errorHandler);
|
||||
};
|
||||
|
||||
$scope.revertTag = function(tag, image_id, callback) {
|
||||
$scope.restoreTag = function(tag, image_id, opt_manifest_digest, callback) {
|
||||
if (!$scope.repository.can_write) { return; }
|
||||
|
||||
var params = {
|
||||
|
@ -127,13 +128,97 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
|||
'image': image_id
|
||||
};
|
||||
|
||||
var errorHandler = ApiService.errorDisplay('Cannot revert tag', callback);
|
||||
ApiService.revertTag(data, params).then(function() {
|
||||
if (opt_manifest_digest) {
|
||||
data['manifest_digest'] = opt_manifest_digest;
|
||||
}
|
||||
|
||||
var errorHandler = ApiService.errorDisplay('Cannot restore tag', callback);
|
||||
ApiService.restoreTag(data, params).then(function() {
|
||||
callback(true);
|
||||
markChanged([], [tag]);
|
||||
}, errorHandler);
|
||||
};
|
||||
|
||||
$scope.editLabels = function(info, callback) {
|
||||
var actions = [];
|
||||
var existingMutableLabels = {};
|
||||
|
||||
// Build the set of adds and deletes.
|
||||
info['updated_labels'].forEach(function(label) {
|
||||
if (label['id']) {
|
||||
existingMutableLabels[label['id']] = true;
|
||||
} else {
|
||||
actions.push({
|
||||
'action': 'add',
|
||||
'label': label
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
info['mutable_labels'].forEach(function(label) {
|
||||
if (!existingMutableLabels[label['id']]) {
|
||||
actions.push({
|
||||
'action': 'delete',
|
||||
'label': label
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// Execute the add and delete label actions.
|
||||
var currentIndex = 0;
|
||||
|
||||
var performAction = function() {
|
||||
if (currentIndex >= actions.length) {
|
||||
$scope.labelsChanged({'manifest_digest': info['manifest_digest']});
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var currentAction = actions[currentIndex];
|
||||
currentIndex++;
|
||||
|
||||
var errorHandler = ApiService.errorDisplay('Could not update labels', callback);
|
||||
switch (currentAction.action) {
|
||||
case 'add':
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'manifestref': info['manifest_digest']
|
||||
};
|
||||
|
||||
var pieces = currentAction['label']['keyValue'].split('=', 2);
|
||||
|
||||
var data = {
|
||||
'key': pieces[0],
|
||||
'value': pieces[1],
|
||||
'media_type': null // Have backend infer the media type
|
||||
};
|
||||
|
||||
ApiService.addManifestLabel(data, params).then(performAction, errorHandler);
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'manifestref': info['manifest_digest'],
|
||||
'labelid': currentAction['label']['id']
|
||||
};
|
||||
|
||||
ApiService.deleteManifestLabel(null, params).then(performAction, errorHandler);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
performAction();
|
||||
};
|
||||
|
||||
var filterLabels = function(labels, readOnly) {
|
||||
if (!labels) { return []; }
|
||||
|
||||
return labels.filter(function(label) {
|
||||
return (label['source_type'] != 'api') == readOnly;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.actionHandler = {
|
||||
'askDeleteTag': function(tag) {
|
||||
$scope.deleteTagInfo = {
|
||||
|
@ -155,18 +240,42 @@ angular.module('quay').directive('tagOperationsDialog', function () {
|
|||
$element.find('#createOrMoveTagModal').modal('show');
|
||||
},
|
||||
|
||||
'askRevertTag': function(tag, image_id) {
|
||||
'showLabelEditor': function(manifest_digest) {
|
||||
$scope.editLabelsInfo = {
|
||||
'manifest_digest': manifest_digest,
|
||||
'loading': true
|
||||
};
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'manifestref': manifest_digest
|
||||
};
|
||||
|
||||
ApiService.listManifestLabels(null, params).then(function(resp) {
|
||||
var labels = resp['labels'];
|
||||
|
||||
$scope.editLabelsInfo['readonly_labels'] = filterLabels(labels, true);
|
||||
$scope.editLabelsInfo['mutable_labels'] = filterLabels(labels, false);
|
||||
|
||||
$scope.editLabelsInfo['labels'] = labels;
|
||||
$scope.editLabelsInfo['loading'] = false;
|
||||
|
||||
}, ApiService.errorDisplay('Could not load manifest labels'));
|
||||
},
|
||||
|
||||
'askRestoreTag': function(tag, image_id, opt_manifest_digest) {
|
||||
if (tag.image_id == image_id) {
|
||||
bootbox.alert('This is the current image for the tag');
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.revertTagInfo = {
|
||||
$scope.restoreTagInfo = {
|
||||
'tag': tag,
|
||||
'image_id': image_id
|
||||
'image_id': image_id,
|
||||
'manifest_digest': opt_manifest_digest
|
||||
};
|
||||
|
||||
$element.find('#revertTagModal').modal('show');
|
||||
$element.find('#restoreTagModal').modal('show');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -22,13 +22,14 @@
|
|||
$scope.logsShown = 0;
|
||||
$scope.buildsShown = 0;
|
||||
$scope.settingsShown = 0;
|
||||
$scope.historyShown = 0;
|
||||
|
||||
$scope.viewScope = {
|
||||
'selectedTags': [],
|
||||
'repository': null,
|
||||
'imageLoader': imageLoader,
|
||||
'builds': null,
|
||||
'changesVisible': false
|
||||
'historyFilter': ''
|
||||
};
|
||||
|
||||
var buildPollChannel = null;
|
||||
|
@ -133,6 +134,10 @@
|
|||
$scope.buildsShown++;
|
||||
};
|
||||
|
||||
$scope.showHistory = function() {
|
||||
$scope.historyShown++;
|
||||
};
|
||||
|
||||
$scope.showSettings = function() {
|
||||
$scope.settingsShown++;
|
||||
};
|
||||
|
@ -147,10 +152,6 @@
|
|||
}, 10);
|
||||
};
|
||||
|
||||
$scope.handleChangesState = function(value) {
|
||||
$scope.viewScope.changesVisible = value;
|
||||
};
|
||||
|
||||
$scope.getImages = function(callback) {
|
||||
loadImages(callback);
|
||||
};
|
||||
|
|
|
@ -20,6 +20,7 @@ var quayDependencies: any[] = [
|
|||
'core-ui',
|
||||
'core-config-setup',
|
||||
'infinite-scroll',
|
||||
'ngTagsInput',
|
||||
'react'
|
||||
];
|
||||
|
||||
|
|
|
@ -45,4 +45,4 @@ angular
|
|||
.constant('NAME_PATTERNS', NAME_PATTERNS)
|
||||
.constant('INJECTED_CONFIG', INJECTED_CONFIG)
|
||||
.constant('INJECTED_FEATURES', INJECTED_FEATURES)
|
||||
.constant('INJECTED_ENDPOINTS', INJECTED_ENDPOINTS);
|
||||
.constant('INJECTED_ENDPOINTS', INJECTED_ENDPOINTS);
|
|
@ -279,6 +279,8 @@ angular.module('quay').factory('VulnerabilityService', ['Config', 'ApiService',
|
|||
|
||||
return {
|
||||
'features': features,
|
||||
'brokenFeaturesCount': features.length - totalCount,
|
||||
'fixableFeatureCount': features.filter(function(f) { return f.fixableScore > 0 }).length,
|
||||
'severityBreakdown': severityBreakdown,
|
||||
'highestFixableScore': highestFixableScore
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ URI.js - MIT (https://github.com/medialize/URI.js)
|
|||
angular-hotkeys - MIT (https://github.com/chieffancypants/angular-hotkeys/blob/master/LICENSE)
|
||||
angular-debounce - MIT (https://github.com/shahata/angular-debounce/blob/master/LICENSE)
|
||||
infinite-scroll - MIT (https://github.com/sroze/ngInfiniteScroll/blob/master/LICENSE)
|
||||
ngTagsInput - MIT (https://github.com/mbenford/ngTagsInput/blob/master/LICENSE)
|
||||
|
||||
Issues:
|
||||
>>>>> jquery.spotlight - GPLv3 (https://github.com/jameshalsall/jQuery-Spotlight)
|
|
@ -47,18 +47,17 @@
|
|||
<i class="fa fa-tags"></i>
|
||||
</span>
|
||||
|
||||
<span class="cor-tab" tab-title="Tag History" tab-target="#history" id="tagHistoryTab"
|
||||
tab-init="showHistory()">
|
||||
<i class="fa fa-history"></i>
|
||||
</span>
|
||||
|
||||
<span class="cor-tab" tab-title="Builds" tab-target="#builds" id="buildsTab"
|
||||
tab-init="showBuilds()"
|
||||
quay-show="viewScope.repository.can_write && Features.BUILD_SUPPORT">
|
||||
<i class="fa fa-tasks"></i>
|
||||
</span>
|
||||
|
||||
<span class="cor-tab" tab-title="Visualize" tab-target="#changes"
|
||||
tab-shown="handleChangesState(true)"
|
||||
tab-hidden="handleChangesState(false)">
|
||||
<i class="fa fa-code-fork"></i>
|
||||
</span>
|
||||
|
||||
<!-- Admin Only Tabs -->
|
||||
<span class="cor-tab" tab-title="Usage Logs" tab-target="#logs" tab-init="showLogs()"
|
||||
ng-show="viewScope.repository.can_admin">
|
||||
|
@ -87,9 +86,20 @@
|
|||
repository="viewScope.repository"
|
||||
image-loader="viewScope.imageLoader"
|
||||
selected-tags="viewScope.selectedTags"
|
||||
history-filter="viewScope.historyFilter"
|
||||
is-enabled="tagsShown"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tag History -->
|
||||
<div id="history" class="tab-pane">
|
||||
<h3 class="tab-header">Tag History</h3>
|
||||
<div class="repo-tag-history"
|
||||
repository="viewScope.repository"
|
||||
filter="viewScope.historyFilter"
|
||||
image-loader="viewScope.imageLoader"
|
||||
is-enabled="historyShown"></div>
|
||||
</div>
|
||||
|
||||
<!-- Builds -->
|
||||
<div id="builds" class="tab-pane">
|
||||
<div class="repo-panel-builds"
|
||||
|
@ -98,15 +108,6 @@
|
|||
is-enabled="buildsShown"></div>
|
||||
</div>
|
||||
|
||||
<!-- Changes -->
|
||||
<div id="changes" class="tab-pane">
|
||||
<div class="repo-panel-changes"
|
||||
repository="viewScope.repository"
|
||||
image-loader="viewScope.imageLoader"
|
||||
selected-tags="viewScope.selectedTags"
|
||||
is-enabled="viewScope.changesVisible"></div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Logs -->
|
||||
<div id="logs" class="tab-pane" ng-if="viewScope.repository.can_admin">
|
||||
<div class="logs-view" repository="viewScope.repository" makevisible="logsShown"></div>
|
||||
|
|
|
@ -12,7 +12,7 @@ from endpoints.api import api_bp, api
|
|||
|
||||
from endpoints.api.team import (TeamMember, TeamMemberList, OrganizationTeam, TeamMemberInvite,
|
||||
TeamPermissions)
|
||||
from endpoints.api.tag import RepositoryTagImages, RepositoryTag, ListRepositoryTags, RevertTag
|
||||
from endpoints.api.tag import RepositoryTagImages, RepositoryTag, ListRepositoryTags, RestoreTag
|
||||
from endpoints.api.search import EntitySearch
|
||||
from endpoints.api.image import RepositoryImage, RepositoryImageList
|
||||
from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs,
|
||||
|
@ -2539,10 +2539,10 @@ class TestRepositoryImage5avqBuynlargeOrgrepo(ApiTestCase):
|
|||
self._run_test('GET', 404, 'devtable', None)
|
||||
|
||||
|
||||
class TestRevertTagHp8rPublicPublicrepo(ApiTestCase):
|
||||
class TestRestoreTagHp8rPublicPublicrepo(ApiTestCase):
|
||||
def setUp(self):
|
||||
ApiTestCase.setUp(self)
|
||||
self._set_url(RevertTag, tag="HP8R", repository="public/publicrepo")
|
||||
self._set_url(RestoreTag, tag="HP8R", repository="public/publicrepo")
|
||||
|
||||
def test_post_anonymous(self):
|
||||
self._run_test('POST', 401, None, {u'image': 'WXNG'})
|
||||
|
@ -2557,10 +2557,10 @@ class TestRevertTagHp8rPublicPublicrepo(ApiTestCase):
|
|||
self._run_test('POST', 403, 'devtable', {u'image': 'WXNG'})
|
||||
|
||||
|
||||
class TestRevertTagHp8rDevtableShared(ApiTestCase):
|
||||
class TestRestoreTagHp8rDevtableShared(ApiTestCase):
|
||||
def setUp(self):
|
||||
ApiTestCase.setUp(self)
|
||||
self._set_url(RevertTag, tag="HP8R", repository="devtable/shared")
|
||||
self._set_url(RestoreTag, tag="HP8R", repository="devtable/shared")
|
||||
|
||||
def test_post_anonymous(self):
|
||||
self._run_test('POST', 401, None, {u'image': 'WXNG'})
|
||||
|
@ -2572,13 +2572,13 @@ class TestRevertTagHp8rDevtableShared(ApiTestCase):
|
|||
self._run_test('POST', 403, 'reader', {u'image': 'WXNG'})
|
||||
|
||||
def test_post_devtable(self):
|
||||
self._run_test('POST', 404, 'devtable', {u'image': 'WXNG'})
|
||||
self._run_test('POST', 400, 'devtable', {u'image': 'WXNG'})
|
||||
|
||||
|
||||
class TestRevertTagHp8rBuynlargeOrgrepo(ApiTestCase):
|
||||
class TestRestoreTagHp8rBuynlargeOrgrepo(ApiTestCase):
|
||||
def setUp(self):
|
||||
ApiTestCase.setUp(self)
|
||||
self._set_url(RevertTag, tag="HP8R", repository="buynlarge/orgrepo")
|
||||
self._set_url(RestoreTag, tag="HP8R", repository="buynlarge/orgrepo")
|
||||
|
||||
def test_post_anonymous(self):
|
||||
self._run_test('POST', 401, None, {u'image': 'WXNG'})
|
||||
|
@ -2590,7 +2590,7 @@ class TestRevertTagHp8rBuynlargeOrgrepo(ApiTestCase):
|
|||
self._run_test('POST', 403, 'reader', {u'image': 'WXNG'})
|
||||
|
||||
def test_post_devtable(self):
|
||||
self._run_test('POST', 404, 'devtable', {u'image': 'WXNG'})
|
||||
self._run_test('POST', 400, 'devtable', {u'image': 'WXNG'})
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ from util.secscan.fake import fake_security_scanner
|
|||
|
||||
from endpoints.api.team import (TeamMember, TeamMemberList, TeamMemberInvite, OrganizationTeam,
|
||||
TeamPermissions, InviteTeamMember)
|
||||
from endpoints.api.tag import RepositoryTagImages, RepositoryTag, RevertTag, ListRepositoryTags
|
||||
from endpoints.api.tag import RepositoryTagImages, RepositoryTag, RestoreTag, ListRepositoryTags
|
||||
from endpoints.api.search import EntitySearch, ConductSearch
|
||||
from endpoints.api.image import RepositoryImage, RepositoryImageList
|
||||
from endpoints.api.build import RepositoryBuildStatus, RepositoryBuildList, RepositoryBuildResource
|
||||
|
@ -2872,24 +2872,32 @@ class TestGetImageChanges(ApiTestCase):
|
|||
# image_id=image_id))
|
||||
|
||||
|
||||
class TestRevertTag(ApiTestCase):
|
||||
def test_reverttag_invalidtag(self):
|
||||
class TestRestoreTag(ApiTestCase):
|
||||
def test_restoretag_invalidtag(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
self.postResponse(RevertTag,
|
||||
self.postResponse(RestoreTag,
|
||||
params=dict(repository=ADMIN_ACCESS_USER + '/history', tag='invalidtag'),
|
||||
data=dict(image='invalid_image'),
|
||||
expected_code=404)
|
||||
expected_code=400)
|
||||
|
||||
def test_reverttag_invalidimage(self):
|
||||
def test_restoretag_invalidimage(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
self.postResponse(RevertTag,
|
||||
self.postResponse(RestoreTag,
|
||||
params=dict(repository=ADMIN_ACCESS_USER + '/history', tag='latest'),
|
||||
data=dict(image='invalid_image'),
|
||||
expected_code=400)
|
||||
|
||||
def test_reverttag(self):
|
||||
def test_restoretag_invalidmanifest(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
self.postResponse(RestoreTag,
|
||||
params=dict(repository=ADMIN_ACCESS_USER + '/history', tag='latest'),
|
||||
data=dict(manifest_digest='invalid_digest'),
|
||||
expected_code=400)
|
||||
|
||||
def test_restoretag(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
json = self.getJsonResponse(ListRepositoryTags,
|
||||
|
@ -2901,8 +2909,8 @@ class TestRevertTag(ApiTestCase):
|
|||
|
||||
previous_image_id = json['tags'][1]['docker_image_id']
|
||||
|
||||
self.postJsonResponse(RevertTag, params=dict(repository=ADMIN_ACCESS_USER + '/history',
|
||||
tag='latest'),
|
||||
self.postJsonResponse(RestoreTag, params=dict(repository=ADMIN_ACCESS_USER + '/history',
|
||||
tag='latest'),
|
||||
data=dict(image=previous_image_id))
|
||||
|
||||
json = self.getJsonResponse(ListRepositoryTags,
|
||||
|
@ -2912,6 +2920,29 @@ class TestRevertTag(ApiTestCase):
|
|||
self.assertFalse('end_ts' in json['tags'][0])
|
||||
self.assertEquals(previous_image_id, json['tags'][0]['docker_image_id'])
|
||||
|
||||
def test_restoretag_to_digest(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
json = self.getJsonResponse(ListRepositoryTags,
|
||||
params=dict(repository=ADMIN_ACCESS_USER + '/history',
|
||||
tag='latest'))
|
||||
|
||||
self.assertEquals(2, len(json['tags']))
|
||||
self.assertFalse('end_ts' in json['tags'][0])
|
||||
|
||||
previous_manifest = json['tags'][1]['manifest_digest']
|
||||
|
||||
self.postJsonResponse(RestoreTag, params=dict(repository=ADMIN_ACCESS_USER + '/history',
|
||||
tag='latest'),
|
||||
data=dict(image='foo', manifest_digest=previous_manifest))
|
||||
|
||||
json = self.getJsonResponse(ListRepositoryTags,
|
||||
params=dict(repository=ADMIN_ACCESS_USER + '/history',
|
||||
tag='latest'))
|
||||
self.assertEquals(3, len(json['tags']))
|
||||
self.assertFalse('end_ts' in json['tags'][0])
|
||||
self.assertEquals(previous_manifest, json['tags'][0]['manifest_digest'])
|
||||
|
||||
|
||||
|
||||
class TestListAndDeleteTag(ApiTestCase):
|
||||
|
@ -3030,6 +3061,13 @@ class TestListAndDeleteTag(ApiTestCase):
|
|||
|
||||
self.assertEquals(prod_images, json['images'])
|
||||
|
||||
def test_listtag_digest(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
json = self.getJsonResponse(ListRepositoryTags,
|
||||
params=dict(repository=ADMIN_ACCESS_USER + '/simple', page=1,
|
||||
limit=1))
|
||||
self.assertTrue('manifest_digest' in json['tags'][0])
|
||||
|
||||
def test_listtagpagination(self):
|
||||
self.login(ADMIN_ACCESS_USER)
|
||||
|
||||
|
|
Reference in a new issue