Change repo stats to use the RAC table and a nice UI
This commit is contained in:
parent
dbe14fe729
commit
853cca35f3
10 changed files with 184 additions and 125 deletions
|
@ -5,7 +5,7 @@ from peewee import JOIN_LEFT_OUTER, SQL, fn
|
|||
from datetime import datetime, timedelta, date
|
||||
from cachetools import lru_cache
|
||||
|
||||
from data.database import LogEntry, LogEntryKind, User, db
|
||||
from data.database import LogEntry, LogEntryKind, User, RepositoryActionCount, db
|
||||
from data.model import config
|
||||
|
||||
def _logs_query(selections, start_time, end_time, performer=None, repository=None, namespace=None,
|
||||
|
@ -95,62 +95,6 @@ def log_action(kind_name, user_or_organization_name, performer=None, repository=
|
|||
datetime=timestamp)
|
||||
|
||||
|
||||
def _get_repository_events(repository, time_delta, time_delta_earlier, clause):
|
||||
""" Returns a pair representing the count of the number of events for the given
|
||||
repository in each of the specified time deltas. The date ranges are calculated by
|
||||
taking the current time today and subtracting the time delta given. Since
|
||||
we want to grab *two* ranges, we restrict the second range to be greater
|
||||
than the first (i.e. referring to an earlier time), so we can conduct the
|
||||
lookup in a single query. The clause is used to further filter the kind of
|
||||
events being found.
|
||||
"""
|
||||
since = date.today() - time_delta
|
||||
since_earlier = date.today() - time_delta_earlier
|
||||
|
||||
if since_earlier >= since:
|
||||
raise ValueError('time_delta_earlier must be greater than time_delta')
|
||||
|
||||
# This uses a CASE WHEN inner clause to further filter the count.
|
||||
formatted = since.strftime('%Y-%m-%d')
|
||||
case_query = 'CASE WHEN datetime >= \'%s\' THEN 1 ELSE 0 END' % formatted
|
||||
|
||||
result = (LogEntry
|
||||
.select(fn.Sum(SQL(case_query)), fn.Count(SQL('*')))
|
||||
.where(LogEntry.repository == repository)
|
||||
.where(clause)
|
||||
.where(LogEntry.datetime >= since_earlier)
|
||||
.tuples()
|
||||
.get())
|
||||
|
||||
return (int(result[0]) if result[0] else 0, int(result[1]) if result[1] else 0)
|
||||
|
||||
|
||||
def get_repository_pushes(repository, time_delta, time_delta_earlier):
|
||||
push_repo = _get_log_entry_kind('push_repo')
|
||||
clauses = (LogEntry.kind == push_repo)
|
||||
return _get_repository_events(repository, time_delta, time_delta_earlier, clauses)
|
||||
|
||||
|
||||
def get_repository_pulls(repository, time_delta, time_delta_earlier):
|
||||
repo_pull = _get_log_entry_kind('pull_repo')
|
||||
repo_verb = _get_log_entry_kind('repo_verb')
|
||||
clauses = ((LogEntry.kind == repo_pull) | (LogEntry.kind == repo_verb))
|
||||
return _get_repository_events(repository, time_delta, time_delta_earlier, clauses)
|
||||
|
||||
|
||||
def get_repository_usage():
|
||||
one_month_ago = date.today() - timedelta(weeks=4)
|
||||
repo_pull = _get_log_entry_kind('pull_repo')
|
||||
repo_verb = _get_log_entry_kind('repo_verb')
|
||||
return (LogEntry
|
||||
.select(LogEntry.ip, LogEntry.repository)
|
||||
.where((LogEntry.kind == repo_pull) | (LogEntry.kind == repo_verb))
|
||||
.where(~(LogEntry.repository >> None))
|
||||
.where(LogEntry.datetime >= one_month_ago)
|
||||
.group_by(LogEntry.ip, LogEntry.repository)
|
||||
.count())
|
||||
|
||||
|
||||
def get_stale_logs_start_id():
|
||||
""" Gets the oldest log entry. """
|
||||
try:
|
||||
|
@ -182,3 +126,28 @@ def get_stale_logs(start_id, end_id):
|
|||
def delete_stale_logs(start_id, end_id):
|
||||
""" Deletes all the logs with IDs between start_id and end_id. """
|
||||
LogEntry.delete().where((LogEntry.id >= start_id), (LogEntry.id <= end_id)).execute()
|
||||
|
||||
|
||||
def get_repository_action_counts(repo, start_date):
|
||||
return RepositoryActionCount.select().where(RepositoryActionCount.repository == repo,
|
||||
RepositoryActionCount.date >= start_date)
|
||||
|
||||
|
||||
def get_repositories_action_sums(repository_ids):
|
||||
if not repository_ids:
|
||||
return {}
|
||||
|
||||
# Filter the join to recent entries only.
|
||||
last_week = datetime.now() - timedelta(weeks=1)
|
||||
tuples = (RepositoryActionCount
|
||||
.select(RepositoryActionCount.repository, fn.Sum(RepositoryActionCount.count))
|
||||
.where(RepositoryActionCount.repository << repository_ids)
|
||||
.where(RepositoryActionCount.date >= last_week)
|
||||
.group_by(RepositoryActionCount.repository)
|
||||
.tuples())
|
||||
|
||||
action_count_map = {}
|
||||
for record in tuples:
|
||||
action_count_map[record[0]] = record[1]
|
||||
|
||||
return action_count_map
|
||||
|
|
|
@ -3,9 +3,9 @@ import logging
|
|||
from peewee import JOIN_LEFT_OUTER, fn
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from data.model import (DataModelException, tag, db_transaction, storage, image, permission,
|
||||
from data.model import (DataModelException, tag, db_transaction, storage, permission,
|
||||
_basequery, config)
|
||||
from data.database import (Repository, Namespace, RepositoryTag, Star, Image, ImageStorage, User,
|
||||
from data.database import (Repository, Namespace, RepositoryTag, Star, Image, User,
|
||||
Visibility, RepositoryPermission, TupleSelector, RepositoryActionCount,
|
||||
Role, RepositoryAuthorizedEmail, TagManifest, DerivedStorageForImage,
|
||||
db_for_update, get_epoch_timestamp, db_random_func)
|
||||
|
@ -227,26 +227,6 @@ def get_when_last_modified(repository_ids):
|
|||
return last_modified_map
|
||||
|
||||
|
||||
def get_action_counts(repository_ids):
|
||||
if not repository_ids:
|
||||
return {}
|
||||
|
||||
# Filter the join to recent entries only.
|
||||
last_week = datetime.now() - timedelta(weeks=1)
|
||||
tuples = (RepositoryActionCount
|
||||
.select(RepositoryActionCount.repository, fn.Sum(RepositoryActionCount.count))
|
||||
.where(RepositoryActionCount.repository << repository_ids)
|
||||
.where(RepositoryActionCount.date >= last_week)
|
||||
.group_by(RepositoryActionCount.repository)
|
||||
.tuples())
|
||||
|
||||
action_count_map = {}
|
||||
for record in tuples:
|
||||
action_count_map[record[0]] = record[1]
|
||||
|
||||
return action_count_map
|
||||
|
||||
|
||||
def get_visible_repositories(username, namespace=None, include_public=False):
|
||||
""" Returns the repositories visible to the given user (if any).
|
||||
"""
|
||||
|
|
|
@ -4,12 +4,11 @@ import logging
|
|||
import datetime
|
||||
import features
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
from flask import request, abort
|
||||
|
||||
from data import model
|
||||
from data.database import Repository as RepositoryTable
|
||||
from endpoints.api import (truthy_bool, format_date, nickname, log_action, validate_json_request,
|
||||
require_repo_read, require_repo_write, require_repo_admin,
|
||||
RepositoryParamResource, resource, query_param, parse_args, ApiResource,
|
||||
|
@ -29,6 +28,7 @@ from util.names import REPOSITORY_NAME_REGEX
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
REPOS_PER_PAGE = 100
|
||||
MAX_DAYS_IN_3_MONTHS = 92
|
||||
|
||||
def check_allowed_private_repos(namespace):
|
||||
""" Checks to see if the given namespace has reached its private repository limit. If so,
|
||||
|
@ -180,7 +180,7 @@ class RepositoryList(ApiResource):
|
|||
last_modified_map = model.repository.get_when_last_modified(repository_ids)
|
||||
|
||||
if parsed_args['popularity']:
|
||||
action_count_map = model.repository.get_action_counts(repository_ids)
|
||||
action_sum_map = model.log.get_repositories_action_sums(repository_ids)
|
||||
|
||||
# Collect the IDs of the repositories that are starred for the user, so we can mark them
|
||||
# in the returned results.
|
||||
|
@ -203,7 +203,7 @@ class RepositoryList(ApiResource):
|
|||
repo['last_modified'] = last_modified_map.get(repo_id)
|
||||
|
||||
if parsed_args['popularity']:
|
||||
repo['popularity'] = action_count_map.get(repo_id, 0)
|
||||
repo['popularity'] = action_sum_map.get(repo_id, 0)
|
||||
|
||||
if username:
|
||||
repo['is_starred'] = repo_id in star_set
|
||||
|
@ -236,7 +236,7 @@ class Repository(RepositoryParamResource):
|
|||
}
|
||||
|
||||
@parse_args()
|
||||
@query_param('includeStats', 'Whether to include pull and push statistics', type=truthy_bool,
|
||||
@query_param('includeStats', 'Whether to include action statistics', type=truthy_bool,
|
||||
default=False)
|
||||
@require_repo_read
|
||||
@nickname('getRepo')
|
||||
|
@ -252,7 +252,7 @@ class Repository(RepositoryParamResource):
|
|||
}
|
||||
|
||||
if tag.lifetime_start_ts > 0:
|
||||
last_modified = format_date(datetime.datetime.fromtimestamp(tag.lifetime_start_ts))
|
||||
last_modified = format_date(datetime.fromtimestamp(tag.lifetime_start_ts))
|
||||
tag_info['last_modified'] = last_modified
|
||||
|
||||
return tag_info
|
||||
|
@ -270,22 +270,28 @@ class Repository(RepositoryParamResource):
|
|||
is_public = model.repository.is_repository_public(repo)
|
||||
|
||||
if parsed_args['includeStats']:
|
||||
(pull_today, pull_thirty_day) = model.log.get_repository_pulls(repo, timedelta(days=1),
|
||||
timedelta(days=30))
|
||||
stats = []
|
||||
found_dates = {}
|
||||
|
||||
(push_today, push_thirty_day) = model.log.get_repository_pushes(repo, timedelta(days=1),
|
||||
timedelta(days=30))
|
||||
start_date = datetime.now() - timedelta(days=MAX_DAYS_IN_3_MONTHS)
|
||||
counts = model.log.get_repository_action_counts(repo, start_date)
|
||||
for count in counts:
|
||||
stats.append({
|
||||
'date': count.date.isoformat(),
|
||||
'count': count.count,
|
||||
})
|
||||
|
||||
stats = {
|
||||
'pulls': {
|
||||
'today': pull_today,
|
||||
'thirty_day': pull_thirty_day
|
||||
},
|
||||
'pushes': {
|
||||
'today': push_today,
|
||||
'thirty_day': push_thirty_day
|
||||
}
|
||||
}
|
||||
found_dates['%s/%s' % (count.date.month, count.date.day)] = True
|
||||
|
||||
# Fill in any missing stats with zeros.
|
||||
for day in range(-31, MAX_DAYS_IN_3_MONTHS):
|
||||
day_date = datetime.now() - timedelta(days=day)
|
||||
key = '%s/%s' % (day_date.month, day_date.day)
|
||||
if not key in found_dates:
|
||||
stats.append({
|
||||
'date': day_date.date().isoformat(),
|
||||
'count': 0,
|
||||
})
|
||||
|
||||
repo_data = {
|
||||
'namespace': namespace,
|
||||
|
|
|
@ -17,6 +17,7 @@ EXTERNAL_JS = [
|
|||
'cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.17.37/js/bootstrap-datetimepicker.min.js',
|
||||
'cdn.jsdelivr.net/g/bootbox@4.1.0,underscorejs@1.5.2,restangular@1.2.0,d3js@3.3.3',
|
||||
'cdn.ravenjs.com/3.1.0/angular/raven.min.js',
|
||||
'cdn.jsdelivr.net/cal-heatmap/3.3.10/cal-heatmap.min.js',
|
||||
]
|
||||
|
||||
EXTERNAL_CSS = [
|
||||
|
@ -25,6 +26,7 @@ EXTERNAL_CSS = [
|
|||
'fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,700',
|
||||
'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',
|
||||
]
|
||||
|
||||
EXTERNAL_FONTS = [
|
||||
|
|
|
@ -56,6 +56,7 @@
|
|||
|
||||
.repo-panel-info-element .stat-col {
|
||||
border-right: 2px solid #eee;
|
||||
padding-right: 26px;
|
||||
}
|
||||
|
||||
.repo-panel-info-element .stat-title {
|
||||
|
@ -65,11 +66,21 @@
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.repo-panel-info-element .stat {
|
||||
.repo-panel-info-element .stat-row {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.repo-panel-info-element .stat-row .heatmap {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.repo-panel-info-element .stat {
|
||||
display: inline-block;
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.repo-panel-info-element .stat .stat-value {
|
||||
font-size: 46px;
|
||||
}
|
||||
|
|
17
static/css/directives/ui/heatmap.css
Normal file
17
static/css/directives/ui/heatmap.css
Normal file
|
@ -0,0 +1,17 @@
|
|||
.heatmap-element {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.heatmap-element svg {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.heatmap-element .cal-heatmap-container {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.heatmap-element .graph-label {
|
||||
font-family: 'Source Sans Pro', sans-serif;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
1
static/directives/heatmap.html
Normal file
1
static/directives/heatmap.html
Normal file
|
@ -0,0 +1 @@
|
|||
<div class="heatmap-element"></div>
|
|
@ -1,38 +1,29 @@
|
|||
<div class="repo-panel-info-element">
|
||||
<!-- Repository stats and builds summary -->
|
||||
<div class="repository-stats row">
|
||||
<!-- Pull Stats -->
|
||||
<div class="col-sm-3 stat-col">
|
||||
<div class="stat-title">Repo Pulls</div>
|
||||
<!-- Stats -->
|
||||
<div class="col-sm-5 stat-col">
|
||||
<div class="stat-title">Repository Activity</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ repository.stats.pulls.today | abbreviated }}</div>
|
||||
<div class="stat-subtitle">Last 24 hours</div>
|
||||
<div class="stat-row">
|
||||
<div class="heatmap hidden-xs hidden-sm" data="repository.stats"
|
||||
item-name="action" domain="month" range="3"
|
||||
start-count="-2" start-domain="months"></div>
|
||||
|
||||
<div class="stat visible-xs visible-sm">
|
||||
<div class="stat-value">{{ getAggregatedUsage(repository.stats, 1) | abbreviated }}</div>
|
||||
<div class="stat-subtitle">Yesterday</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ repository.stats.pulls.thirty_day | abbreviated }}</div>
|
||||
<div class="stat visible-xs visible-sm">
|
||||
<div class="stat-value">{{ getAggregatedUsage(repository.stats, 30) | abbreviated }}</div>
|
||||
<div class="stat-subtitle">Last 30 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Push Stats -->
|
||||
<div class="col-sm-3 stat-col">
|
||||
<div class="stat-title">Repo Pushes</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ repository.stats.pushes.today | abbreviated }}</div>
|
||||
<div class="stat-subtitle">Last 24 hours</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-value">{{ repository.stats.pushes.thirty_day | abbreviated }}</div>
|
||||
<div class="stat-subtitle">Last 30 days</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Builds -->
|
||||
<div class="col-sm-6 builds-list">
|
||||
<div class="col-sm-7 builds-list">
|
||||
<div class="stat-title">Recent Repo Builds</div>
|
||||
|
||||
<!-- Loading -->
|
||||
|
|
|
@ -27,6 +27,21 @@ angular.module('quay').directive('repoPanelInfo', function () {
|
|||
$scope.repository.description = content;
|
||||
$scope.repository.put();
|
||||
};
|
||||
|
||||
$scope.getAggregatedUsage = function(stats, days) {
|
||||
var count = 0;
|
||||
var startDate = moment().subtract(days + 1, 'days');
|
||||
for (var i = 0; i < stats.length; ++i) {
|
||||
var stat = stats[i];
|
||||
var statDate = moment(stat['date']);
|
||||
if (statDate.isBefore(startDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
count += stat['count'];
|
||||
}
|
||||
return count;
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
|
|
67
static/js/directives/ui/heatmap.js
Normal file
67
static/js/directives/ui/heatmap.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* An element which displays a date+count heatmap.
|
||||
*/
|
||||
angular.module('quay').directive('heatmap', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/heatmap.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'data': '=data',
|
||||
'startCount': '@startCount',
|
||||
'startDomain': '@startDomain',
|
||||
|
||||
'itemName': '@itemName',
|
||||
'domain': '@domain',
|
||||
'range': '@range'
|
||||
},
|
||||
controller: function($scope, $element, $timeout) {
|
||||
var cal = null;
|
||||
|
||||
var refresh = function() {
|
||||
var data = $scope.data;
|
||||
if (!data) { return; }
|
||||
|
||||
if (!cal) {
|
||||
var start = moment().add($scope.startCount * 1, $scope.startDomain).toDate();
|
||||
|
||||
cal = new CalHeatMap();
|
||||
cal.init({
|
||||
itemName: $scope.itemName,
|
||||
domain: $scope.domain,
|
||||
range: $scope.range * 1,
|
||||
|
||||
start: start,
|
||||
itemSelector: $element.find('.heatmap-element')[0],
|
||||
cellSize: 15,
|
||||
domainMargin: [10, 10, 10, 10],
|
||||
displayLegend: false,
|
||||
tooltip: true,
|
||||
legendColors: {
|
||||
empty: "#f4f4f4",
|
||||
min: "#c9e9fb",
|
||||
max: "steelblue",
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cal.update(formatData(data));
|
||||
};
|
||||
|
||||
var formatData = function(data) {
|
||||
var timestamps = {};
|
||||
data.forEach(function(entry) {
|
||||
timestamps[moment(entry.date).unix()] = entry.count;
|
||||
});
|
||||
return timestamps;
|
||||
};
|
||||
|
||||
$scope.$watch('data', function() {
|
||||
$timeout(refresh, 500);
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
Reference in a new issue