Merge pull request #2416 from coreos-inc/new-tags-ui

Updated Tags and Tag History UI
This commit is contained in:
josephschorr 2017-03-14 17:11:21 -04:00 committed by GitHub
commit 9297373bd6
45 changed files with 1250 additions and 639 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
.donut-chart-element svg {
display: inline-block;
width: auto;
vertical-align: middle;
}

View file

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

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

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

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

View file

@ -0,0 +1,8 @@
.manifest-label-list-element {
padding-left: 6px;
}
.manifest-label-list-element .none {
font-size: 12px;
color: #ccc;
}

View file

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

View file

@ -20,4 +20,9 @@
list-style: none;
display: inline-block;
margin: 4px;
}
.tag-operations-dialog .label-section {
margin-bottom: 10px;
padding: 6px;
}

View file

@ -0,0 +1 @@
<span class="donut-chart-element" ng-style="{'line-height': size + 'px'}"></span>

View file

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

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

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

View file

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

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

View file

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

View file

@ -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');
}
};
}

View file

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

View file

@ -20,6 +20,7 @@ var quayDependencies: any[] = [
'core-ui',
'core-config-setup',
'infinite-scroll',
'ngTagsInput',
'react'
];

View file

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

View file

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

View file

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

View file

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

View file

@ -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'})

View file

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