Add a history view to the tags page. Next step will add the ability to revert back in time

This commit is contained in:
Joseph Schorr 2015-04-15 15:21:09 -04:00
parent 703f48f194
commit f8c80f7d11
10 changed files with 492 additions and 13 deletions

View file

@ -1762,6 +1762,16 @@ def _tag_alive(query, now_ts=None):
(RepositoryTag.lifetime_end_ts > now_ts))
def list_repository_tag_history(repository, limit=100):
query = (RepositoryTag
.select(RepositoryTag, Image)
.join(Image)
.where(RepositoryTag.repository == repository)
.order_by(RepositoryTag.lifetime_start_ts.desc())
.limit(limit))
return query
def list_repository_tags(namespace_name, repository_name, include_hidden=False,
include_storage=False):

View file

@ -1,12 +1,45 @@
from flask import request
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)
path_param, format_date)
from endpoints.api.image import image_view
from data import model
from auth.auth_context import get_authenticated_user
from datetime import datetime
@resource('/v1/repository/<repopath:repository>/tag/')
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
class ListRepositoryTags(RepositoryParamResource):
""" Resource for listing repository tags. """
@require_repo_write
@nickname('listRepoTags')
def get(self, namespace, repository):
repo = model.get_repository(namespace, repository)
if not repo:
abort(404)
def tag_view(tag):
tag_info = {
'name': tag.name,
'docker_image_id': tag.image.docker_image_id,
}
if tag.lifetime_start_ts > 0:
tag_info['start_ts'] = tag.lifetime_start_ts
if tag.lifetime_end_ts > 0:
tag_info['end_ts'] = tag.lifetime_end_ts
return tag_info
tags = model.list_repository_tag_history(repo, limit=100)
return {'tags': [tag_view(tag) for tag in tags]}
@resource('/v1/repository/<repopath:repository>/tag/<tag>')
@path_param('repository', 'The full path of the repository. e.g. namespace/name')

View file

@ -57,7 +57,7 @@ def __gen_image_uuid(repo, image_num):
global_image_num = [0]
def __create_subtree(repo, structure, creator_username, parent):
def __create_subtree(repo, structure, creator_username, parent, tag_map):
num_nodes, subtrees, last_node_tags = structure
# create the nodes
@ -102,12 +102,18 @@ def __create_subtree(repo, structure, creator_username, parent):
tag = model.create_or_update_tag(repo.namespace_user.username, repo.name, tag_name,
new_image.docker_image_id)
tag_map[tag_name] = tag
for tag_name in last_node_tags:
if tag_name[0] == '#':
tag.lifetime_end_ts = get_epoch_timestamp() - 1
tag = tag_map[tag_name]
tag.name = tag_name[1:]
tag.lifetime_end_ts = tag_map[tag_name[1:]].lifetime_start_ts
tag.lifetime_start_ts = tag.lifetime_end_ts - 10
tag.save()
for subtree in subtrees:
__create_subtree(repo, subtree, creator_username, new_image)
__create_subtree(repo, subtree, creator_username, new_image, tag_map)
def __generate_repository(user, name, description, is_public, permissions,
@ -127,9 +133,9 @@ def __generate_repository(user, name, description, is_public, permissions,
if isinstance(structure, list):
for s in structure:
__create_subtree(repo, s, user.username, None)
__create_subtree(repo, s, user.username, None, {})
else:
__create_subtree(repo, structure, user.username, None)
__create_subtree(repo, structure, user.username, None, {})
return repo

View file

@ -68,4 +68,131 @@
.repo-panel-tags-element .options-col .fa-download {
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 {
font-size: 12px;
color: #ccc;
}

View file

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

View file

@ -0,0 +1,2 @@
<a href="/repository/{{ repository.namespace }}/{{ repository.name }}/image/{{ imageId }}"
class="image-link-element">{{ imageId.substr(0, 12) }}</a>

View file

@ -1,6 +1,72 @@
<div class="repo-panel-tags-element">
<div class="tab-header-controls">
<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
</button>
<button class="btn" ng-class="showingHistory ? 'btn-info active' : 'btn-default'" ng-click="showHistory(true)">
<i class="fa fa-history"></i>History
</button>
</div>
</div>
<h3 class="tab-header">Repository Tags</h3>
<div class="resource-view" resource="imagesResource" error-message="'Could not load images'">
<!-- 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>
<!-- Normal View -->
<div class="resource-view" resource="imagesResource" error-message="'Could not load images'"
ng-show="!showingHistory">
<div class="co-check-bar">
<span class="cor-checkable-menu" controller="checkedTags">
<div class="cor-checkable-menu-item" item-filter="allTagFilter">
@ -23,7 +89,12 @@
<a href="javascript:void(0)" class="btn btn-default" ng-click="setTab('changes')">
<i class="fa fa-code-fork"></i> Visualize
</a>
<button class="btn btn-default"
<a href="javascript:void(0)" class="btn btn-default"
ng-click="showHistory(true, getTagNames(checkedTags.checked))"
ng-if="repository.can_write">
<i class="fa fa-history"></i> View History
</a>
<button class="btn btn-primary"
ng-click="askDeleteMultipleTags(checkedTags.checked)"
ng-if="repository.can_write">
<i class="fa fa-times"></i> Delete
@ -69,9 +140,7 @@
</td>
<td>{{ tag.size | bytes }}</td>
<td class="image-id-col">
<a ng-href="/repository/{{ repository.namespace }}/{{ repository.name }}/image/{{ tag.image_id }}">
{{ tag.image_id.substr(0, 12) }}
</a>
<span class="image-link" repoository="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"
@ -86,6 +155,10 @@
</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>

View file

@ -25,6 +25,7 @@ angular.module('quay').directive('repoPanelTags', function () {
$scope.iterationState = {};
$scope.tagActionHandler = null;
$scope.showingHistory = false;
var setTagState = function() {
if (!$scope.repository || !$scope.selectedTags) { return; }
@ -118,8 +119,142 @@ 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.showingHistory = value;
if ($scope.showingHistory) {
loadTimeline();
}
};
$scope.toggleHistory = function() {
$scope.showHistory(!$scope.showingHistory);
};
$scope.trackLineClass = 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);
@ -206,6 +341,22 @@ angular.module('quay').directive('repoPanelTags', function () {
return $scope.imageIDFilter(it.image_id, tag);
});
};
$scope.getTagNames = function(checked) {
var names = checked.map(function(tag) {
return tag.name;
});
return names.join(',');
};
$scope.isChecked = function(tagName, checked) {
return checked.some(function(tag) {
if (tag.name == tagName) {
return true;
}
});
};
}
};
return directiveDefinitionObject;

View file

@ -0,0 +1,19 @@
/**
* An element which displays a link to a repository image.
*/
angular.module('quay').directive('imageLink', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/image-link.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'repository': '=repository',
'imageId': '=imageId'
},
controller: function($scope, $element) {
}
};
return directiveDefinitionObject;
});

View file

@ -11,7 +11,7 @@ from initdb import setup_database_for_testing, finished_database_for_testing
from endpoints.api import api_bp, api
from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam, TeamMemberInvite
from endpoints.api.tag import RepositoryTagImages, RepositoryTag
from endpoints.api.tag import RepositoryTagImages, RepositoryTag, ListRepositoryTags
from endpoints.api.search import FindRepositories, EntitySearch
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs,
@ -1753,6 +1753,60 @@ class TestRepositoryBuildLogsS5j8BuynlargeOrgrepo(ApiTestCase):
self._run_test('GET', 400, 'devtable', None)
class TestListRepositoryTagsTn96PublicPublicrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(ListRepositoryTags, tag="TN96", repository="public/publicrepo")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 403, 'devtable', None)
class TestListRepositoryTagsTn96DevtableShared(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(ListRepositoryTags, tag="TN96", repository="devtable/shared")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 200, 'devtable', None)
class TestListRepositoryTagsTn96BuynlargeOrgrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(ListRepositoryTags, tag="TN96", repository="buynlarge/orgrepo")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 200, 'devtable', None)
class TestRepositoryTagImagesTn96PublicPublicrepo(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)