Add support for full logging of all actions in Quay, and the ability to view and filter these logs in the org’s admin view
This commit is contained in:
parent
d5c0f768c2
commit
cca5daf097
16 changed files with 25024 additions and 16 deletions
124
static/js/app.js
124
static/js/app.js
|
@ -650,6 +650,130 @@ quayApp.directive('dockerAuthDialog', function () {
|
|||
});
|
||||
|
||||
|
||||
quayApp.filter('visibleLogFilter', function () {
|
||||
return function (logs, allowed) {
|
||||
if (!allowed) {
|
||||
return logs;
|
||||
}
|
||||
|
||||
var filtered = [];
|
||||
angular.forEach(logs, function (log) {
|
||||
if (allowed[log.kind]) {
|
||||
filtered.push(log);
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
quayApp.directive('logsView', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/logs-view.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'organization': '=organization',
|
||||
'user': '=user',
|
||||
'visible': '=visible'
|
||||
},
|
||||
controller: function($scope, $element, $sce, Restangular) {
|
||||
$scope.loading = true;
|
||||
$scope.logs = null;
|
||||
$scope.kindsAllowed = null;
|
||||
$scope.chartVisible = true;
|
||||
|
||||
var logKinds = {
|
||||
'account_change_plan': 'Change plan',
|
||||
'account_change_cc': 'Update credit card',
|
||||
'account_change_password': 'Change password',
|
||||
'account_convert': 'Convert account to organization',
|
||||
'create_robot': 'Create Robot Account',
|
||||
'delete_robot': 'Delete Robot Account',
|
||||
'create_repo': 'Create Repository',
|
||||
'push_repo': 'Push to repository',
|
||||
'pull_repo': 'Pull repository',
|
||||
'delete_repo': 'Delete repository',
|
||||
'add_repo_permission': 'Add user permission to repository',
|
||||
'change_repo_permission': 'Change repository permission',
|
||||
'delete_repo_permission': 'Remove user permission from repository',
|
||||
'change_repo_visibility': 'Change repository visibility',
|
||||
'add_repo_accesstoken': 'Create access token',
|
||||
'delete_repo_accesstoken': 'Delete access token',
|
||||
'add_repo_webhook': 'Add webhook',
|
||||
'delete_repo_webhook': 'Delete webhook',
|
||||
'set_repo_description': 'Change repository description',
|
||||
'build_dockerfile': 'Build image from Dockerfile',
|
||||
'org_create_team': 'Create team',
|
||||
'org_delete_team': 'Delete team',
|
||||
'org_add_team_member': 'Add team member',
|
||||
'org_remove_team_member': 'Remove team member',
|
||||
'org_set_team_description': 'Change team description',
|
||||
'org_set_team_role': 'Change team permission'
|
||||
};
|
||||
|
||||
var update = function() {
|
||||
if (!$scope.visible || (!$scope.organization && !$scope.user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.logs) {
|
||||
return;
|
||||
}
|
||||
|
||||
var url = $scope.organization ? getRestUrl('organization', $scope.organization.name, 'logs') :
|
||||
getRestUrl('user/logs');
|
||||
var loadLogs = Restangular.one(url);
|
||||
loadLogs.customGET().then(function(resp) {
|
||||
if (!$scope.chart) {
|
||||
$scope.chart = new LogUsageChart(resp.logs, logKinds);
|
||||
$scope.chart.draw('bar-chart');
|
||||
$($scope.chart).bind('filteringChanged', function(e) {
|
||||
$scope.$apply(function() { $scope.kindsAllowed = e.allowed; });
|
||||
});
|
||||
}
|
||||
|
||||
$scope.logs = resp.logs;
|
||||
$scope.loading = false;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.toggleChart = function() {
|
||||
$scope.chartVisible = !$scope.chartVisible;
|
||||
};
|
||||
|
||||
$scope.isVisible = function(allowed, kind) {
|
||||
return allowed == null || allowed.hasOwnProperty(kind);
|
||||
};
|
||||
|
||||
$scope.getColor = function(kind) {
|
||||
return $scope.chart.getColor(kind);
|
||||
};
|
||||
|
||||
$scope.getDescription = function(log) {
|
||||
var description = log.description;
|
||||
for (var key in log.metadata) {
|
||||
if (log.metadata.hasOwnProperty(key)) {
|
||||
var markedDown = getMarkedDown(log.metadata[key].toString());
|
||||
markedDown = markedDown.substr('<p>'.length, markedDown.length - '<p></p>'.length);
|
||||
description = description.replace('{' + key + '}', '<code>' + markedDown + '</code>');
|
||||
}
|
||||
}
|
||||
return $sce.trustAsHtml(description);
|
||||
};
|
||||
|
||||
$scope.$watch('organization', update);
|
||||
$scope.$watch('user', update);
|
||||
$scope.$watch('visible', update);
|
||||
}
|
||||
};
|
||||
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
quayApp.directive('robotsManager', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
|
|
|
@ -1154,7 +1154,12 @@ function OrgAdminCtrl($rootScope, $scope, Restangular, $routeParams, UserService
|
|||
$scope.membersLoading = true;
|
||||
$scope.membersFound = null;
|
||||
$scope.invoiceLoading = true;
|
||||
$scope.logsShown = false;
|
||||
|
||||
$scope.loadLogs = function() {
|
||||
$scope.logsShown = true;
|
||||
};
|
||||
|
||||
$scope.planChanged = function(plan) {
|
||||
$scope.hasPaidPlan = plan && plan.price > 0;
|
||||
};
|
||||
|
|
|
@ -1261,4 +1261,221 @@ RepositoryUsageChart.prototype.draw = function(container) {
|
|||
this.arc_ = arc;
|
||||
this.width_ = cw;
|
||||
this.drawInternal_();
|
||||
};
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* A chart which displays the last seven days of actions in the account.
|
||||
*/
|
||||
function LogUsageChart(logData, titleMap) {
|
||||
this.logs_ = logData;
|
||||
this.titleMap_ = titleMap;
|
||||
this.colorScale_ = d3.scale.category20();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Builds the D3-representation of the data.
|
||||
*/
|
||||
LogUsageChart.prototype.buildData_ = function() {
|
||||
var parseDate = d3.time.format("%a, %d %b %Y %H:%M:%S GMT").parse
|
||||
|
||||
// Build entries for each kind of event that occurred, on each day. We have one
|
||||
// entry per {kind, day} pair.
|
||||
var map = {};
|
||||
var entries = [];
|
||||
for (var i = 0; i < this.logs_.length; ++i) {
|
||||
var log = this.logs_[i];
|
||||
var title = this.titleMap_[log.kind] || log.kind;
|
||||
var datetime = parseDate(log.datetime);
|
||||
var formatted = (datetime.getMonth() + 1) + '/' + datetime.getDate();
|
||||
var adjusted = new Date(datetime.getFullYear(), datetime.getMonth(), datetime.getDate());
|
||||
var key = title + '_' + formatted;
|
||||
var found = map[key];
|
||||
if (!found) {
|
||||
found = {
|
||||
'kind': log.kind,
|
||||
'title': title,
|
||||
'adjusted': adjusted,
|
||||
'formatted': datetime.getDate(),
|
||||
'count': 0
|
||||
};
|
||||
|
||||
map[key] = found;
|
||||
entries.push(found);
|
||||
}
|
||||
found['count']++;
|
||||
}
|
||||
|
||||
this.entries_ = map;
|
||||
|
||||
// Build the data itself. We create a single entry for each possible kind of data, and then add (x, y) pairs
|
||||
// for the number of times that kind of event occurred on a particular day.
|
||||
var dataArray = [];
|
||||
var dataMap = {};
|
||||
var dateMap = {};
|
||||
|
||||
for (var i = 0; i < entries.length; ++i) {
|
||||
var entry = entries[i];
|
||||
var key = entry.title;
|
||||
var found = dataMap[key];
|
||||
if (!found) {
|
||||
found = {'key': key, 'values': [], 'kind': entry.kind};
|
||||
dataMap[key] = found;
|
||||
dataArray.push(found);
|
||||
}
|
||||
|
||||
found.values.push({
|
||||
'x': entry.adjusted,
|
||||
'y': entry.count
|
||||
});
|
||||
|
||||
dateMap[entry.adjusted.toString()] = entry.adjusted;
|
||||
}
|
||||
|
||||
// Note: nvd3 has a bug that causes d3 to fail if there is not an entry for every single
|
||||
// kind on each day that has data. Therefore, we pad those days with 0-length entries for each
|
||||
// kind.
|
||||
for (var i = 0; i < dataArray.length; ++i) {
|
||||
var datum = dataArray[i];
|
||||
for (var sDate in dateMap) {
|
||||
if (!dateMap.hasOwnProperty(sDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var cDate = dateMap[sDate];
|
||||
var found = false;
|
||||
for (var j = 0; j < datum.values.length; ++j) {
|
||||
if (datum.values[j]['x'].getDate() == cDate.getDate()) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
datum.values.push({
|
||||
'x': cDate,
|
||||
'y': 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
datum.values.sort(function(a, b) {
|
||||
return a['x'].getDate() - b['x'].getDate();
|
||||
});
|
||||
}
|
||||
|
||||
return this.data_ = dataArray;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Renders the tooltip when hovering over an element in the chart.
|
||||
*/
|
||||
LogUsageChart.prototype.renderTooltip_ = function(d, e) {
|
||||
var entry = this.entries_[d + '_' + e];
|
||||
if (!entry) {
|
||||
entry = {'count': 0};
|
||||
}
|
||||
|
||||
var s = entry.count == 1 ? '' : 's';
|
||||
return d + ' - ' + entry.count + ' time' + s + ' on ' + e;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Returns the color used in the chart for log entries of the given
|
||||
* kind.
|
||||
*/
|
||||
LogUsageChart.prototype.getColor = function(kind) {
|
||||
var colors = this.colorScale_.range();
|
||||
var index = 0;
|
||||
for (var i = 0; i < this.data_.length; ++i) {
|
||||
var datum = this.data_[i];
|
||||
var key = this.titleMap_[kind] || kind;
|
||||
if (datum.key == key) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return colors[index];
|
||||
};
|
||||
|
||||
|
||||
LogUsageChart.prototype.handleStateChange_ = function(e) {
|
||||
var allowed = {};
|
||||
var disabled = e.disabled;
|
||||
for (var i = 0; i < this.data_.length; ++i) {
|
||||
if (!disabled[i]) {
|
||||
allowed[this.data_[i].kind] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$(this).trigger({
|
||||
'type': 'filteringChanged',
|
||||
'allowed': allowed
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Draws the chart in the given container element.
|
||||
*/
|
||||
LogUsageChart.prototype.draw = function(container) {
|
||||
// Returns a date offset from the given date by "days" Days.
|
||||
var offsetDate = function(d, days) {
|
||||
var copy = new Date(d.getTime());
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
};
|
||||
|
||||
var that = this;
|
||||
var data = this.buildData_();
|
||||
nv.addGraph(function() {
|
||||
// Build the chart itself.
|
||||
var chart = nv.models.multiBarChart()
|
||||
.margin({top: 30, right: 30, bottom: 50, left: 30})
|
||||
.stacked(false)
|
||||
.staggerLabels(false)
|
||||
.tooltip(function(d, e) {
|
||||
return that.renderTooltip_(d, e);
|
||||
})
|
||||
.color(that.colorScale_.range())
|
||||
.groupSpacing(0.1);
|
||||
|
||||
chart.multibar.delay(0);
|
||||
|
||||
// Create the x-axis domain to encompass a week from today.
|
||||
var domain = [];
|
||||
var datetime = new Date();
|
||||
datetime = new Date(datetime.getFullYear(), datetime.getMonth(), datetime.getDate());
|
||||
for (var i = 7; i >= 0; --i) {
|
||||
domain.push(offsetDate(datetime, -1 * i));
|
||||
}
|
||||
|
||||
chart.xDomain(domain);
|
||||
|
||||
// Finish setting up the chart.
|
||||
chart.xAxis
|
||||
.tickFormat(d3.time.format("%m/%d"));
|
||||
|
||||
chart.yAxis
|
||||
.tickFormat(d3.format(',f'));
|
||||
|
||||
d3.select('#bar-chart svg')
|
||||
.datum(data)
|
||||
.transition()
|
||||
.duration(500)
|
||||
.call(chart);
|
||||
|
||||
nv.utils.windowResize(chart.update);
|
||||
|
||||
chart.multibar.dispatch.on('elementClick', function(e) { window.console.log(e); });
|
||||
chart.dispatch.on('stateChange', function(e) { that.handleStateChange_(e); });
|
||||
|
||||
return chart;
|
||||
});
|
||||
};
|
Reference in a new issue