Merge branch 'master' of github.com:coreos-inc/quay

This commit is contained in:
Joseph Schorr 2015-04-19 15:16:39 -04:00
commit 2815ea2723
16 changed files with 601 additions and 320 deletions

View file

@ -497,6 +497,7 @@ class RepositoryTag(BaseModel):
lifetime_start_ts = IntegerField(default=get_epoch_timestamp)
lifetime_end_ts = IntegerField(null=True, index=True)
hidden = BooleanField(default=False)
reversion = BooleanField(default=False)
class Meta:
database = db

View file

@ -0,0 +1,29 @@
"""Add revert_tag log entry kind
Revision ID: 1c3decf6b9c4
Revises: 4ce2169efd3b
Create Date: 2015-04-16 17:14:11.154856
"""
# revision identifiers, used by Alembic.
revision = '1c3decf6b9c4'
down_revision = '4ce2169efd3b'
from alembic import op
import sqlalchemy as sa
def upgrade(tables):
op.bulk_insert(tables.logentrykind,
[
{'id': 47, 'name':'revert_tag'},
])
def downgrade(tables):
op.execute(
(tables.logentrykind.delete()
.where(tables.logentrykind.c.name == op.inline_literal('revert_tag')))
)

View file

@ -0,0 +1,26 @@
"""Add reversion column to the tags table
Revision ID: 4ce2169efd3b
Revises: 2b4dc0818a5e
Create Date: 2015-04-16 17:10:16.039835
"""
# revision identifiers, used by Alembic.
revision = '4ce2169efd3b'
down_revision = '2b4dc0818a5e'
from alembic import op
import sqlalchemy as sa
def upgrade(tables):
### commands auto generated by Alembic - please adjust! ###
op.add_column('repositorytag', sa.Column('reversion', sa.Boolean(), nullable=False))
### end Alembic commands ###
def downgrade(tables):
### commands auto generated by Alembic - please adjust! ###
op.drop_column('repositorytag', 'reversion')
### end Alembic commands ###

View file

@ -1762,13 +1762,17 @@ def _tag_alive(query, now_ts=None):
(RepositoryTag.lifetime_end_ts > now_ts))
def list_repository_tag_history(repository, limit=100):
def list_repository_tag_history(repository, limit=100, specific_tag=None):
query = (RepositoryTag
.select(RepositoryTag, Image)
.join(Image)
.where(RepositoryTag.repository == repository)
.order_by(RepositoryTag.lifetime_start_ts.desc())
.limit(limit))
if specific_tag:
query = query.where(RepositoryTag.name == specific_tag)
return query
@ -1990,7 +1994,7 @@ def get_parent_images(namespace_name, repository_name, image_obj):
def create_or_update_tag(namespace_name, repository_name, tag_name,
tag_docker_image_id):
tag_docker_image_id, reversion=False):
try:
repo = _get_repository(namespace_name, repository_name)
except Repository.DoesNotExist:
@ -2015,7 +2019,7 @@ def create_or_update_tag(namespace_name, repository_name, tag_name,
raise DataModelException('Invalid image with id: %s' % tag_docker_image_id)
return RepositoryTag.create(repository=repo, image=image, name=tag_name,
lifetime_start_ts=now_ts)
lifetime_start_ts=now_ts, reversion=reversion)
def delete_tag(namespace_name, repository_name, tag_name):
now_ts = get_epoch_timestamp()
@ -2823,3 +2827,14 @@ def repository_is_starred(user, repository):
return True
except Star.DoesNotExist:
return False
def revert_tag(namespace_name, repository_name, tag_name, docker_image_id):
""" Reverts a tag to a specific image ID. """
image = get_image_by_id(namespace_name, repository_name, docker_image_id)
if image is None:
raise DataModelException('Cannot revert to unknown image')
return create_or_update_tag(namespace_name, repository_name, tag_name, docker_image_id,
reversion=True)

View file

@ -2,7 +2,7 @@ from flask import request, abort
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
RepositoryParamResource, log_action, NotFound, validate_json_request,
path_param, format_date)
path_param, format_date, parse_args, query_param)
from endpoints.api.image import image_view
from data import model
from auth.auth_context import get_authenticated_user
@ -17,8 +17,11 @@ class ListRepositoryTags(RepositoryParamResource):
""" Resource for listing repository tags. """
@require_repo_write
@parse_args
@query_param('specificTag', 'Filters the tags to the specific tag.', type=str, default='')
@query_param('limit', 'Limit to the number of results to return. Max 100.', type=int, default=50)
@nickname('listRepoTags')
def get(self, namespace, repository):
def get(self, args, namespace, repository):
repo = model.get_repository(namespace, repository)
if not repo:
abort(404)
@ -27,6 +30,7 @@ class ListRepositoryTags(RepositoryParamResource):
tag_info = {
'name': tag.name,
'docker_image_id': tag.image.docker_image_id,
'reversion': tag.reversion,
}
if tag.lifetime_start_ts > 0:
@ -37,7 +41,9 @@ class ListRepositoryTags(RepositoryParamResource):
return tag_info
tags = model.list_repository_tag_history(repo, limit=100)
specific_tag = args.get('specificTag') or None
limit = min(100, max(1, args.get('limit', 50)))
tags = model.list_repository_tag_history(repo, limit=limit, specific_tag=specific_tag)
return {'tags': [tag_view(tag) for tag in tags]}
@ -134,3 +140,54 @@ class RepositoryTagImages(RepositoryParamResource):
return {
'images': [image_view(image, image_map) for image in all_images]
}
@resource('/v1/repository/<repopath:repository>/tag/<tag>/revert')
@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. """
schemas = {
'RevertTag': {
'id': 'RevertTag',
'type': 'object',
'description': 'Reverts a tag to a specific image',
'required': [
'image',
],
'properties': {
'image': {
'type': 'string',
'description': 'Image identifier to which the tag should point',
},
},
},
}
@require_repo_write
@nickname('revertTag')
@validate_json_request('RevertTag')
def post(self, namespace, repository, tag):
""" Reverts a repository tag back to a previous image in the repository. """
try:
tag_image = model.get_tag_image(namespace, repository, tag)
except model.DataModelException:
raise NotFound()
# Revert the tag back to the previous image.
image_id = request.get_json()['image']
model.revert_tag(namespace, repository, tag, image_id)
model.garbage_collect_repository(namespace, repository)
# Log the reversion.
username = get_authenticated_user().username
log_action('revert_tag', namespace,
{'username': username, 'repo': repository, 'tag': tag,
'image': image_id, 'original_image': tag_image.docker_image_id},
repo=model.get_repository(namespace, repository))
return {
'image_id': image_id,
'original_image_id': tag_image.docker_image_id
}

View file

@ -222,6 +222,7 @@ def initialize_database():
LogEntryKind.create(name='create_tag')
LogEntryKind.create(name='move_tag')
LogEntryKind.create(name='delete_tag')
LogEntryKind.create(name='revert_tag')
LogEntryKind.create(name='add_repo_permission')
LogEntryKind.create(name='change_repo_permission')
LogEntryKind.create(name='delete_repo_permission')

View file

@ -65,134 +65,22 @@
padding-left: 20px;
}
.repo-panel-tags-element .options-col .fa-download {
.repo-panel-tags-element .options-col .fa-download, .repo-panel-tags-element .options-col .fa-history {
color: #999;
cursor: pointer;
}
.repo-panel-tags-element .history-list {
margin: 10px;
border-left: 2px solid #eee;
}
.repo-panel-tags-element .history-entry {
position:relative;
margin-top: 20px;
padding-left: 26px;
transition: all 350ms ease-in-out;
}
.repo-panel-tags-element .history-entry .history-text {
transition: transform 350ms ease-in-out, opacity 350ms ease-in-out;
overflow: hidden;
height: 40px;
}
.repo-panel-tags-element .history-entry.filtered-mismatch {
margin-top: 10px;
}
.repo-panel-tags-element .history-entry.filtered-mismatch .history-text {
height: 18px;
opacity: 0;
}
.repo-panel-tags-element .history-entry.filtered-mismatch .history-icon {
opacity: 0.5;
transform: scale(0.5, 0.5);
}
.repo-panel-tags-element .history-entry .history-date-break {
font-size: 16px;
}
.repo-panel-tags-element .history-entry .history-date-break:before {
content: "";
position: absolute;
border-radius: 50%;
width: 12px;
height: 12px;
background: #ccc;
top: 4px;
left: -7px;
}
.repo-panel-tags-element .history-entry .history-icon {
border-radius: 50%;
width: 32px;
height: 32px;
line-height: 33px;
text-align: center;
font-size: 20px;
color: white;
background: #ccc;
position: absolute;
left: -17px;
top: -4px;
display: inline-block;
transition: all 350ms ease-in-out;
}
.repo-panel-tags-element .history-entry.move .history-icon:before {
content: "\f061";
font-family: FontAwesome;
}
.repo-panel-tags-element .history-entry.create .history-icon:before {
content: "\f02b";
font-family: FontAwesome;
}
.repo-panel-tags-element .history-entry.delete .history-icon:before {
content: "\f014";
font-family: FontAwesome;
}
.repo-panel-tags-element .history-entry.move .history-icon {
background-color: #1f77b4;
}
.repo-panel-tags-element .history-entry.create .history-icon {
background-color: #98df8a;
}
.repo-panel-tags-element .history-entry.delete .history-icon {
background-color: #ff9896;
}
.repo-panel-tags-element .history-entry .history-icon .fa-tag {
margin-right: 0px;
}
.repo-panel-tags-element .history-entry .tag-span {
display: inline-block;
border-radius: 4px;
padding: 2px;
background: #eee;
padding-right: 6px;
color: black;
cursor: pointer;
}
.repo-panel-tags-element .history-entry .tag-span.checked {
background: #F6FCFF;
}
.repo-panel-tags-element .history-entry .tag-span:before {
content: "\f02b";
font-family: FontAwesome;
margin-left: 4px;
margin-right: 4px;
}
.repo-panel-tags-element .history-entry .history-description {
color: #777;
}
.repo-panel-tags-element .history-entry .history-datetime {
.repo-panel-tags-element .tag-image-history-item .image-id {
font-family: Consolas, "Lucida Console", Monaco, monospace;
font-size: 12px;
}
.repo-panel-tags-element .tag-image-history-item .image-apply-time {
color: #ccc;
font-size: 11px;
padding-left: 20px;
}
.repo-panel-tags-element .tag-image-history-item .fa-circle-o {
margin-right: 2px;
}

View file

@ -0,0 +1,141 @@
.repo-tag-history-element .history-list {
margin: 10px;
border-left: 2px solid #eee;
margin-right: 150px;
}
.repo-tag-history-element .history-entry {
position:relative;
margin-top: 20px;
padding-left: 26px;
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;
overflow: hidden;
height: 40px;
}
.repo-tag-history-element .history-entry.filtered-mismatch {
margin-top: 10px;
}
.repo-tag-history-element .history-entry.filtered-mismatch .history-text {
height: 18px;
opacity: 0;
}
.repo-tag-history-element .history-entry.filtered-mismatch .history-icon {
opacity: 0.5;
transform: scale(0.5, 0.5);
}
.repo-tag-history-element .history-entry.filtered-mismatch.current .history-icon {
background-color: #ccc !important;
}
.repo-tag-history-element .history-entry .history-date-break {
font-size: 16px;
}
.repo-tag-history-element .history-entry .history-date-break:before {
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;
line-height: 32px;
text-align: center;
font-size: 20px;
color: white;
background: #ccc;
display: inline-block;
transition: all 350ms ease-in-out;
}
.repo-tag-history-element .history-entry.move .history-icon:before {
content: "\f061";
font-family: FontAwesome;
}
.repo-tag-history-element .history-entry.create .history-icon:before {
content: "\f02b";
font-family: FontAwesome;
}
.repo-tag-history-element .history-entry.revert .history-icon:before {
content: "\f0e2";
font-family: FontAwesome;
}
.repo-tag-history-element .history-entry.delete .history-icon:before {
content: "\f014";
font-family: FontAwesome;
}
.repo-tag-history-element .history-entry.current.revert .history-icon {
background-color: #F0C577;
}
.repo-tag-history-element .history-entry.current.move .history-icon {
background-color: #77BFF0;
}
.repo-tag-history-element .history-entry.current.create .history-icon {
background-color: #98df8a;
}
.repo-tag-history-element .history-entry.current.delete .history-icon {
background-color: #ff9896;
}
.repo-tag-history-element .history-entry .history-icon .fa-tag {
margin-right: 0px;
}
.repo-tag-history-element .history-entry .tag-span {
display: inline-block;
border-radius: 4px;
padding: 2px;
background: #eee;
padding-right: 6px;
color: black;
cursor: pointer;
}
.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 .history-description {
color: #777;
}
.repo-tag-history-element .history-entry .history-datetime {
font-size: 12px;
color: #ccc;
}

View file

@ -0,0 +1,4 @@
.tag-operations-dialog .image-id {
font-family: Consolas, "Lucida Console", Monaco, monospace;
font-size: 12px;
}

View file

@ -0,0 +1,51 @@
<div class="repo-tag-history-element">
<div class="cor-loader" ng-if="!historyEntries"></div>
<span class="co-filter-box" style="float:right">
<input class="form-control" type="text" ng-model="filter" placeholder="Filter History...">
</span>
<div class="history-list">
<div class="empty" ng-if="!historyEntries.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 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>
</span>
<span ng-switch-when="delete">
was deleted
</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>
</div>

View file

@ -1,5 +1,5 @@
<div class="repo-panel-tags-element">
<div class="tab-header-controls">
<div class="tab-header-controls" ng-show="images">
<div class="btn-group btn-group-sm" ng-show="repository.can_write">
<button class="btn" ng-class="!showingHistory ? 'btn-primary active' : 'btn-default'" ng-click="showHistory(false)">
<i class="fa fa-tags"></i>Current Tags
@ -13,56 +13,8 @@
<h3 class="tab-header">Repository Tags</h3>
<!-- History view -->
<div ng-show="showingHistory">
<div ng-show="showingHistory">
<div class="cor-loader" ng-if="!tagHistoryData"></div>
</div>
<div style="height: 40px;">
<span class="co-filter-box" style="float:right">
<input class="form-control" type="text" ng-model="options.historyFilter" placeholder="Filter History...">
</span>
</div>
<div class="history-list">
<div class="empty" ng-if="!tagHistoryData.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 class="history-entry" ng-repeat="entry in tagHistoryData"
ng-class="getEntryClasses(entry, options.historyFilter)">
<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"></div>
<div class="history-text">
<div class="history-description">
<span class="tag-span"
ng-class="isChecked(entry.tag_name, checkedTags.checked) ? 'checked' : ''"
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>
</span>
<span ng-switch-when="delete">
was deleted
</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>
</div>
<div class="history-datetime">{{ entry.time | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="repo-tag-history" repository="repository" filter="options.historyFilter"
is-enabled="showingHistory" ng-show="showingHistory"></div>
<!-- Normal View -->
<div class="resource-view" resource="imagesResource" error-message="'Could not load images'"
@ -125,6 +77,7 @@
style="min-width: 120px;">
<a href="javascript:void(0)" ng-click="orderBy('image_id')">Image</a>
</td>
<td class="options-col" ng-if="repository.can_write"></td>
<td class="options-col"></td>
<td class="options-col"></td>
</thead>
@ -140,7 +93,7 @@
</td>
<td>{{ tag.size | bytes }}</td>
<td class="image-id-col">
<span class="image-link" repoository="repository" image-id="tag.image_id"></span>
<span class="image-link" repository="repository" image-id="tag.image_id"></span>
</td>
<td class="image-track" ng-repeat="it in imageTracks">
<span class="image-track-dot" ng-if="it.image_id == tag.image_id"
@ -153,12 +106,30 @@
ng-click="fetchTagActionHandler.askFetchTag(tag)">
</i>
</td>
<td class="options-col" ng-if="repository.can_write">
<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]">
<a href="javascript:void(0)" ng-click="askRevertTag(tag, entry.docker_image_id)">
<div class="image-id">
<i class="fa fa-circle-o"
ng-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 class="cor-options-menu" ng-if="repository.can_write">
<span class="cor-option" option-click="showHistory(true, tag.name)"
ng-if="tag.last_modified">
<i class="fa fa-history"></i> View Tag History
</span>
<span class="cor-option" option-click="askDeleteTag(tag.name)">
<i class="fa fa-times"></i> Delete Tag
</span>
@ -185,5 +156,4 @@
<div class="tag-operations-dialog" repository="repository" images="images"
action-handler="tagActionHandler"></div>
<div class="fetch-tag-dialog" repository="repository" action-handler="fetchTagActionHandler">
</div>
<div class="fetch-tag-dialog" repository="repository" action-handler="fetchTagActionHandler"></div>

View file

@ -85,3 +85,20 @@
</div>
</div>
</div>
<!-- Recert 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">
<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>
</div>
</div>

View file

@ -24,6 +24,7 @@ angular.module('quay').directive('repoPanelTags', function () {
};
$scope.iterationState = {};
$scope.tagHistory = {};
$scope.tagActionHandler = null;
$scope.showingHistory = false;
@ -84,6 +85,9 @@ angular.module('quay').directive('repoPanelTags', function () {
'count': imageMap[image_id].length,
'tags': imageMap[image_id]
});
imageMap[image_id]['color'] = colors(index);
++index;
}
});
@ -119,136 +123,11 @@ angular.module('quay').directive('repoPanelTags', function () {
// Process each of the tags.
setTagState();
if ($scope.showingHistory) {
loadTimeline();
}
});
var loadTimeline = function() {
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name
};
ApiService.listRepoTags(null, params).then(function(resp) {
var tagData = [];
var currentTags = {};
resp.tags.forEach(function(tag) {
var tagName = tag.name;
var imageId = tag.docker_image_id;
var oldImageId = null;
if (tag.end_ts) {
var action = 'delete';
// If the end time matches the existing start time for this tag, then this is a move
// instead of a delete.
var currentTime = tag.end_ts * 1000;
if (currentTags[tagName] && currentTags[tagName].start_ts == tag.end_ts) {
action = 'move';
// Remove the create.
var index = tagData.indexOf(currentTags[tagName]);
var createEntry = tagData.splice(index, 1)[0];
imageId = createEntry.docker_image_id;
oldImageId = tag.docker_image_id;
}
// Add the delete/move.
tagData.push({
'tag_name': tagName,
'action': action,
'start_ts': tag.start_ts,
'end_ts': tag.end_ts,
'time': currentTime,
'docker_image_id': imageId,
'old_docker_image_id': oldImageId
})
}
if (tag.start_ts) {
var currentTime = tag.start_ts * 1000;
var create = {
'tag_name': tagName,
'action': 'create',
'start_ts': tag.start_ts,
'end_ts': tag.end_ts,
'time': currentTime,
'docker_image_id': tag.docker_image_id,
'old_docker_image_id': ''
};
tagData.push(create);
currentTags[tagName] = create;
}
});
tagData.sort(function(a, b) {
return b.time - a.time;
});
for (var i = tagData.length - 1; i >= 1; --i) {
var current = tagData[i];
var next = tagData[i - 1];
if (new Date(current.time).getDate() != new Date(next.time).getDate()) {
tagData.splice(i - 1, 0, {
'date_break': true,
'date': new Date(current.time)
});
i--;
}
}
if (tagData.length > 0) {
tagData.splice(0, 0, {
'date_break': true,
'date': new Date(tagData[0].time)
});
}
$scope.tagHistoryData = tagData;
});
};
$scope.getEntryClasses = function(entry, historyFilter) {
var classes = entry.action + ' ';
if (!historyFilter || !entry.action) {
return classes;
}
var parts = (historyFilter || '').split(',');
var isMatch = parts.some(function(part) {
if (part && entry.tag_name) {
isMatch = entry.tag_name.indexOf(part) >= 0;
isMatch = isMatch || entry.docker_image_id.indexOf(part) >= 0;
isMatch = isMatch || entry.old_docker_image_id.indexOf(part) >= 0;
return isMatch;
}
});
classes += isMatch ? 'filtered-match' : 'filtered-mismatch';
return classes;
};
$scope.showHistory = function(value, opt_tagname) {
if (opt_tagname) {
$scope.options.historyFilter = opt_tagname;
} else {
$scope.options.historyFilter = '';
}
if ($scope.showingHistory == value) {
return;
}
$scope.options.historyFilter = opt_tagname ? opt_tagname : '';
$scope.showingHistory = value;
if ($scope.showingHistory) {
loadTimeline();
}
};
$scope.toggleHistory = function() {
@ -350,12 +229,22 @@ angular.module('quay').directive('repoPanelTags', function () {
return names.join(',');
};
$scope.isChecked = function(tagName, checked) {
return checked.some(function(tag) {
if (tag.name == tagName) {
return true;
}
});
$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) {
$scope.tagActionHandler.askRevertTag(tag, image_id);
};
}
};

View file

@ -98,6 +98,7 @@ angular.module('quay').directive('logsView', function () {
return 'Remove permission for token {token} from repository {repo}';
}
},
'revert_tag': 'Tag {tag} reverted to image {image} from image {original_image}',
'delete_tag': 'Tag {tag} deleted in repository {repo} by user {username}',
'create_tag': 'Tag {tag} created in repository {repo} on image {image} by user {username}',
'move_tag': 'Tag {tag} moved from image {original_image} to image {image} in repository {repo} by user {username}',
@ -213,6 +214,7 @@ angular.module('quay').directive('logsView', function () {
'delete_tag': 'Delete Tag',
'create_tag': 'Create Tag',
'move_tag': 'Move Tag',
'revert_tag':' Revert Tag',
'org_create_team': 'Create team',
'org_delete_team': 'Delete team',
'org_add_team_member': 'Add team member',

View file

@ -0,0 +1,157 @@
/**
* An element which displays its contents wrapped in an <a> tag, but only if the href is not null.
*/
angular.module('quay').directive('repoTagHistory', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/repo-tag-history.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'repository': '=repository',
'filter': '=filter',
'isEnabled': '=isEnabled'
},
controller: function($scope, $element, ApiService) {
$scope.tagHistoryData = null;
$scope.tagHistoryLeaves = {};
var loadTimeline = function() {
if (!$scope.repository || !$scope.isEnabled) { return; }
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name
};
ApiService.listRepoTags(null, params).then(function(resp) {
processTags(resp.tags);
});
};
$scope.$watch('isEnabled', loadTimeline);
$scope.$watch('repository', loadTimeline);
var processTags = function(tags) {
var entries = [];
var tagEntries = {};
// For each tag, turn the tag into create, move, delete, restore, etc entries.
tags.forEach(function(tag) {
var tagName = tag.name;
var dockerImageId = tag.docker_image_id;
if (!tagEntries[tagName]) {
tagEntries[tagName] = [];
}
var removeEntry = function(entry) {
entries.splice(entries.indexOf(entry), 1);
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 entry = {
'tag_name': tagName,
'action': action,
'start_ts': tag.start_ts,
'end_ts': tag.end_ts,
'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 || ''
};
tagEntries[tagName].push(entry);
entries.push(entry);
};
// If the tag has an end time, it was either deleted or moved.
if (tag.end_ts) {
// If a future entry exists with a start time equal to the end time for this tag,
// then the action was a move, rather than a delete and a create.
var currentEntries = tagEntries[tagName];
var futureEntry = currentEntries.length > 0 ? currentEntries[currentEntries.length - 1] : {};
if (futureEntry.start_ts == tag.end_ts) {
removeEntry(futureEntry);
addEntry(futureEntry.reversion ? 'revert': 'move', tag.end_ts,
futureEntry.docker_image_id, dockerImageId);
} else {
addEntry('delete', tag.end_ts)
}
}
// If the tag has a start time, it was created.
if (tag.start_ts) {
addEntry('create', tag.start_ts);
}
});
// Sort the overall entries by datetime descending.
entries.sort(function(a, b) {
return b.time - a.time;
});
// Sort the tag entries by datetime descending.
Object.keys(tagEntries).forEach(function(name) {
var te = tagEntries[name];
te.sort(function(a, b) {
return b.time - a.time;
});
});
// Add date dividers in.
for (var i = entries.length - 1; i >= 1; --i) {
var current = entries[i];
var next = entries[i - 1];
if (new Date(current.time).getDate() != new Date(next.time).getDate()) {
entries.splice(i, 0, {
'date_break': true,
'date': new Date(current.time)
});
i--;
}
}
// Add the top-level date divider.
if (entries.length > 0) {
entries.splice(0, 0, {
'date_break': true,
'date': new Date(entries[0].time)
});
}
$scope.historyEntries = entries;
$scope.historyEntryMap = tagEntries;
};
$scope.getEntryClasses = function(entry, historyFilter) {
if (!entry.action) { return ''; }
var classes = entry.action + ' ';
if ($scope.historyEntryMap[entry.tag_name][0] == entry) {
classes += ' current ';
}
if (!historyFilter || !entry.action) {
return classes;
}
var parts = (historyFilter || '').split(',');
var isMatch = parts.some(function(part) {
if (part && entry.tag_name) {
isMatch = entry.tag_name.indexOf(part) >= 0;
isMatch = isMatch || entry.docker_image_id.indexOf(part) >= 0;
isMatch = isMatch || entry.old_docker_image_id.indexOf(part) >= 0;
return isMatch;
}
});
classes += isMatch ? 'filtered-match' : 'filtered-mismatch';
return classes;
};
}
};
return directiveDefinitionObject;
});

View file

@ -121,6 +121,25 @@ angular.module('quay').directive('tagOperationsDialog', function () {
}, errorHandler);
};
$scope.revertTag = function(tag, image_id, callback) {
if (!$scope.repository.can_write) { return; }
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'tag': tag.name
};
var data = {
'image': image_id
};
var errorHandler = ApiService.errorDisplay('Cannot revert tag', callback);
ApiService.revertTag(data, params).then(function() {
callback(true);
markChanged([], [tag]);
}, errorHandler);
};
$scope.actionHandler = {
'askDeleteTag': function(tag) {
$scope.deleteTagInfo = {
@ -140,6 +159,20 @@ angular.module('quay').directive('tagOperationsDialog', function () {
$scope.addingTag = false;
$scope.addTagForm.$setPristine();
$element.find('#createOrMoveTagModal').modal('show');
},
'askRevertTag': function(tag, image_id) {
if (tag.image_id == image_id) {
bootbox.alert('This is the current image for the tag');
return;
}
$scope.revertTagInfo = {
'tag': tag,
'image_id': image_id
};
$element.find('#revertTagModal').modal('show');
}
};
}