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

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