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:
Joseph Schorr 2013-11-27 02:29:31 -05:00
parent d5c0f768c2
commit cca5daf097
16 changed files with 25024 additions and 16 deletions

View file

@ -211,7 +211,23 @@ class QueueItem(BaseModel):
retries_remaining = IntegerField(default=5)
class LogEntryKind(BaseModel):
name = CharField(index=True)
class LogEntry(BaseModel):
kind = ForeignKeyField(LogEntryKind, index=True)
account = ForeignKeyField(User, index=True, related_name = 'account')
performer = ForeignKeyField(User, index=True, null=True, related_name = 'performer')
repository = ForeignKeyField(Repository, index=True, null=True)
access_token = ForeignKeyField(AccessToken, null=True)
datetime = DateTimeField(default=datetime.now, index=True)
ip = CharField(null=True)
description = TextField(null=True)
metadata_json = TextField(default='{}')
all_models = [User, Repository, Image, AccessToken, Role,
RepositoryPermission, Visibility, RepositoryTag,
EmailConfirmation, FederatedLogin, LoginService, QueueItem,
RepositoryBuild, Team, TeamMember, TeamRole, Webhook]
RepositoryBuild, Team, TeamMember, TeamRole, Webhook, LogEntryKind, LogEntry]

View file

@ -1,9 +1,11 @@
import bcrypt
import logging
import datetime
import dateutil.parser
import operator
import json
from datetime import timedelta
from database import *
from util.validation import *
from util.names import format_robot_username
@ -201,7 +203,7 @@ def create_team(name, org, team_role_name, description=''):
description=description)
def __get_user_admin_teams(org_name, username):
def __get_user_admin_teams(org_name, teamname, username):
Org = User.alias()
user_teams = Team.select().join(TeamMember).join(User)
with_org = user_teams.switch(Team).join(Org,
@ -1053,3 +1055,16 @@ def list_webhooks(namespace_name, repository_name):
def delete_webhook(namespace_name, repository_name, public_id):
webhook = get_webhook(namespace_name, repository_name, public_id)
webhook.delete_instance()
def list_logs(user_or_organization_name):
account = User.get(User.username == user_or_organization_name)
week_ago = datetime.today() - timedelta(7) # One week
return LogEntry.select().where(LogEntry.account == account, LogEntry.datetime >= week_ago).order_by(LogEntry.datetime.desc())
def log_action(kind_name, user_or_organization_name, performer=None, repository=None,
access_token=None, ip=None, description=None, metadata={}):
kind = LogEntryKind.get(LogEntryKind.name == kind_name)
account = User.get(User.username == user_or_organization_name)
entry = LogEntry.create(kind = kind, account = account, performer = performer,
repository = repository, access_token = access_token, ip = ip,
description = description, metadata_json = json.dumps(metadata))

View file

@ -17,6 +17,7 @@ from app import app
from util.email import send_confirmation_email, send_recovery_email
from util.names import parse_repository_name, format_robot_username
from util.gravatar import compute_hash
from auth.permissions import (ReadRepositoryPermission,
ModifyRepositoryPermission,
AdministerRepositoryPermission,
@ -34,6 +35,11 @@ user_files = app.config['USERFILES']
logger = logging.getLogger(__name__)
def log_action(kind, user_or_orgname, description=None, metadata={}, repo=None):
parent = current_user.db_user()
model.log_action(kind, user_or_orgname, performer = parent, ip = request.remote_addr,
description = description, metadata = metadata, repository = repo)
def api_login_required(f):
@wraps(f)
def decorated_view(*args, **kwargs):
@ -142,6 +148,7 @@ def convert_user_to_organization():
# Convert the user to an organization.
model.convert_user_to_organization(user, model.get_user(admin_username))
log_action('account_convert', user.username, 'Convert account to an organization')
# And finally login with the admin credentials.
return conduct_signin(admin_username, admin_password)
@ -158,6 +165,7 @@ def change_user_details():
try:
if 'password' in user_data:
logger.debug('Changing password for user: %s', user.username)
log_action('account_change_password', user.username, 'Change account password')
model.change_password(user, user_data['password'])
if 'invoice_email' in user_data:
@ -486,14 +494,20 @@ def update_organization_team(orgname, teamname):
org = model.get_organization(orgname)
team = model.create_team(teamname, org, role, description)
log_action('org_create_team', orgname, 'Creation of team {team}', {'team': teamname})
if is_existing:
if 'description' in details:
team.description = details['description']
team.save()
log_action('org_set_team_description', orgname, 'Set description for {team}: {description}',
{'team': teamname, 'description': team.description})
if 'role' in details:
team = model.set_team_org_permission(team, details['role'],
current_user.db_user().username)
log_action('org_set_team_role', orgname, 'Set role for {team} to {role}',
{'team': teamname, 'role': details['role']})
resp = jsonify(team_view(orgname, team))
if not is_existing:
@ -510,6 +524,7 @@ def delete_organization_team(orgname, teamname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
model.remove_team(orgname, teamname, current_user.db_user().username)
log_action('org_delete_team', orgname, 'Deletion of team {team}', {'team': teamname})
return make_response('Deleted', 204)
abort(403)
@ -560,7 +575,7 @@ def update_organization_team_member(orgname, teamname, membername):
# Add the user to the team.
model.add_user_to_team(user, team)
log_action('org_add_team_member', orgname, 'Add member {member} to team {team}', {'member': membername, 'team': teamname})
return jsonify(member_view(user))
abort(403)
@ -575,6 +590,7 @@ def delete_organization_team_member(orgname, teamname, membername):
# Remote the user from the team.
invoking_user = current_user.db_user().username
model.remove_user_from_team(orgname, teamname, membername, invoking_user)
log_action('org_remove_team_member', orgname, 'Remove member {member} from team {team}', {'member': membername, 'team': teamname})
return make_response('Deleted', 204)
abort(403)
@ -603,6 +619,8 @@ def create_repo_api():
repo.description = req['description']
repo.save()
log_action('create_repo', namespace_name, 'Create repository {repo}',
{'repo': repository_name, 'namespace': namespace_name}, repo=repo)
return jsonify({
'namespace': namespace_name,
'name': repository_name
@ -686,6 +704,9 @@ def update_repo_api(namespace, repository):
values = request.get_json()
repo.description = values['description']
repo.save()
log_action('set_repo_description', namespace, 'Set description of repository {repo}: {description}',
{'repo': repository, 'description': values['description']}, repo=repo)
return jsonify({
'success': True
})
@ -704,6 +725,8 @@ def change_repo_visibility_api(namespace, repository):
if repo:
values = request.get_json()
model.set_repository_visibility(repo, values['visibility'])
log_action('change_repo_visibility', namespace, 'Change visibility of repository {repo}: {visibility}',
{'repo': repository, 'visibility': values['visibility']}, repo=repo)
return jsonify({
'success': True
})
@ -719,6 +742,7 @@ def delete_repository(namespace, repository):
if permission.can():
model.purge_repository(namespace, repository)
registry.delete_repository_storage(namespace, repository)
log_action('delete_repo', namespace, 'Delete repository {repo}', {'repo': repository, 'namespace': namespace})
return make_response('Deleted', 204)
abort(403)
@ -834,6 +858,9 @@ def request_repo_build(namespace, repository):
tag)
dockerfile_build_queue.put(json.dumps({'build_id': build_request.id}))
log_action('build_dockerfile', namespace, 'Build dockerfile and update repository {repo}',
{'repo': repository, 'namespace': namespace, 'fileid': dockerfile_id}, repo=repo)
resp = jsonify({
'started': True
})
@ -862,6 +889,8 @@ def create_webhook(namespace, repository):
repo_string = '%s/%s' % (namespace, repository)
resp.headers['Location'] = url_for('get_webhook', repository=repo_string,
public_id=webhook.public_id)
log_action('add_repo_webhook', namespace, 'Create push webhook {webhook_id} on repo {repo}',
{'repo': repository, 'webhook_id': webhook.public_id}, repo=repo)
return resp
abort(403) # Permissions denied
@ -902,6 +931,9 @@ def delete_webhook(namespace, repository, public_id):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
model.delete_webhook(namespace, repository, public_id)
log_action('delete_repo_webhook', namespace, 'Delete webhook {webhook_id} on repository {repo}',
{'repo': repository, 'webhook_id': public_id},
repo=model.get_repository(namespace, repository))
return make_response('No Content', 204)
abort(403) # Permission denied
@ -1142,6 +1174,9 @@ def change_user_permissions(namespace, repository, username):
error_resp.status_code = 400
return error_resp
log_action('change_repo_permission', namespace, 'Change permissions for user {username} on repository {repo} to {role}',
{'username': username, 'repo': repository, 'role': new_permission['role']},
repo=model.get_repository(namespace, repository))
resp = jsonify(perm_view)
if request.method == 'POST':
@ -1166,6 +1201,10 @@ def change_team_permissions(namespace, repository, teamname):
perm = model.set_team_repo_permission(teamname, namespace, repository,
new_permission['role'])
log_action('change_repo_permission', namespace, 'Change permissions for team {team} on repository {repo} to {role}',
{'team': team, 'repo': repository, 'role': new_permission['role']},
repo=model.get_repository(namespace, repository))
resp = jsonify(role_view(perm))
if request.method == 'POST':
resp.status_code = 201
@ -1189,6 +1228,9 @@ def delete_user_permissions(namespace, repository, username):
})
error_resp.status_code = 400
return error_resp
log_action('delete_repo_permission', namespace, 'Delete permissions for user {username} on repository {repo}',
{'username': username, 'repo': repository}, repo=model.get_repository(namespace, repository))
return make_response('Deleted', 204)
@ -1203,6 +1245,10 @@ def delete_team_permissions(namespace, repository, teamname):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
model.delete_team_permission(teamname, namespace, repository)
log_action('delete_repo_permission', namespace, 'Delete permissions for team {team} on repository {repo}',
{'team': teamname, 'repo': repository}, repo=model.get_repository(namespace, repository))
return make_response('Deleted', 204)
abort(403) # Permission denied
@ -1254,6 +1300,10 @@ def create_token(namespace, repository):
token = model.create_delegate_token(namespace, repository,
token_params['friendlyName'])
log_action('add_repo_accesstoken', namespace, 'Add access token {name} for repository {repo}',
{'repo': repository, 'name': token_params['friendlyName']},
repo = model.get_repository(namespace, repository))
resp = jsonify(token_view(token))
resp.status_code = 201
return resp
@ -1275,6 +1325,9 @@ def change_token(namespace, repository, code):
token = model.set_repo_delegate_token_role(namespace, repository, code,
new_permission['role'])
log_action('change_repo_permission', namespace, 'Change permissions for access token {code} in repository {repo}',
{'repo': repository, 'code': code}, repo = model.get_repository(namespace, repository))
resp = jsonify(token_view(token))
return resp
@ -1289,6 +1342,10 @@ def delete_token(namespace, repository, code):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
model.delete_delegate_token(namespace, repository, code)
log_action('delete_repo_accesstoken', namespace, 'Delete access token {code} in repository {repo}',
{'repo': repository, 'code': code}, repo = model.get_repository(namespace, repository))
return make_response('Deleted', 204)
abort(403) # Permission denied
@ -1326,7 +1383,9 @@ def get_org_card_api(orgname):
def set_user_card_api():
user = current_user.db_user()
token = request.get_json()['token']
return set_card(user, token)
response = set_card(user, token)
log_action('account_change_cc', user.username, 'Change account credit card')
return response
@app.route('/api/organization/<orgname>/card', methods=['POST'])
@ -1336,7 +1395,9 @@ def set_org_card_api(orgname):
if permission.can():
organization = model.get_organization(orgname)
token = request.get_json()['token']
return jsonify(set_card(organization, token))
response = set_card(organization, token)
log_action('account_change_cc', orgname, 'Change organization account credit card')
return response
abort(403)
@ -1425,6 +1486,7 @@ def subscribe(user, plan, token, accepted_plans):
cus = stripe.Customer.create(email=user.email, plan=plan, card=card)
user.stripe_id = cus.id
user.save()
log_action('account_change_plan', user.username, 'Change subscription plan', {'plan': plan})
except stripe.CardError as e:
return carderror_response(e)
@ -1440,6 +1502,7 @@ def subscribe(user, plan, token, accepted_plans):
# We only have to cancel the subscription if they actually have one
cus.cancel_subscription()
cus.save()
log_action('account_change_plan', user.username, 'Change subscription plan', {'plan': plan})
else:
# User may have been a previous customer who is resubscribing
@ -1454,6 +1517,7 @@ def subscribe(user, plan, token, accepted_plans):
return carderror_response(e)
response_json = subscription_view(cus.subscription, private_repos)
log_action('account_change_plan', user.username, 'Change subscription plan', {'plan': plan})
resp = jsonify(response_json)
resp.status_code = status_code
@ -1581,6 +1645,7 @@ def create_robot(robot_shortname):
parent = current_user.db_user()
robot, password = model.create_robot(robot_shortname, parent)
resp = jsonify(robot_view(robot.username, password))
log_action('create_robot', parent.username, 'Creation of robot account {robot}', {'robot': robot_shortname})
resp.status_code = 201
return resp
@ -1594,6 +1659,7 @@ def create_org_robot(orgname, robot_shortname):
parent = model.get_organization(orgname)
robot, password = model.create_robot(robot_shortname, parent)
resp = jsonify(robot_view(robot.username, password))
log_action('create_robot', orgname, 'Creation of robot account {robot}', {'robot': robot_shortname})
resp.status_code = 201
return resp
@ -1605,6 +1671,7 @@ def create_org_robot(orgname, robot_shortname):
def delete_robot(robot_shortname):
parent = current_user.db_user()
model.delete_robot(format_robot_username(parent.username, robot_shortname))
log_action('delete_robot', parent.username, 'Deletion of robot account {robot}', {'robot': robot_shortname})
return make_response('No Content', 204)
@ -1615,6 +1682,33 @@ def delete_org_robot(orgname, robot_shortname):
permission = AdministerOrganizationPermission(orgname)
if permission.can():
model.delete_robot(format_robot_username(orgname, robot_shortname))
log_action('delete_robot', orgname, 'Deletion of robot account {robot}', {'robot': robot_shortname})
return make_response('No Content', 204)
abort(403)
@app.route('/api/organization/<orgname>/logs', methods=['GET'])
@api_login_required
def org_logs_api(orgname):
def log_view(log):
return {
'kind': log.kind.name,
'description': log.description,
'metadata': json.loads(log.metadata_json),
'ip': log.ip,
'performer': {
'username': log.performer.username,
'is_robot': log.performer.robot,
},
'datetime': log.datetime,
}
permission = AdministerOrganizationPermission(orgname)
if permission.can():
logs = model.list_logs(orgname)
return jsonify({
'logs': [log_view(log) for log in logs]
})
abort(403)

View file

@ -16,6 +16,7 @@ from auth.permissions import (ModifyRepositoryPermission, UserPermission,
ReadRepositoryPermission,
CreateRepositoryPermission)
from util.log import log_action
logger = logging.getLogger(__name__)
@ -169,11 +170,28 @@ def create_repository(namespace, repository):
'repository': '%s/%s' % (namespace, repository),
}
metadata = {
'repo': repository,
'namespace': namespace
}
description = ''
if get_authenticated_user():
mixpanel.track(get_authenticated_user().username, 'push_repo',
extra_params)
metadata['username'] = get_authenticated_user().username
description = 'Repository {repo} pushed by user {username}'
else:
mixpanel.track(get_validated_token().code, 'push_repo', extra_params)
metadata['token'] = get_validated_token().friendly_name
metadata['token_code'] = get_validated_token().code
description = 'Repository {repo} pushed via access token {token}'
model.log_action('push_repo', namespace, performer = get_authenticated_user(), ip = request.remote_addr,
description = description,
metadata = metadata,
repository = repo)
return response
@ -232,8 +250,8 @@ def get_repository_images(namespace, repository):
permission = ReadRepositoryPermission(namespace, repository)
# TODO invalidate token?
if permission.can() or model.repository_is_public(namespace, repository):
is_public = model.repository_is_public(namespace, repository)
if permission.can() or is_public:
# We can't rely on permissions to tell us if a repo exists anymore
repo = model.get_repository(namespace, repository)
if not repo:
@ -257,8 +275,17 @@ def get_repository_images(namespace, repository):
extra_params = {
'repository': '%s/%s' % (namespace, repository),
}
mixpanel.track(pull_username, 'pull_repo', extra_params)
mixpanel.track(pull_username, 'pull_repo', extra_params)
model.log_action('pull_repo', namespace, performer = get_authenticated_user(), ip = request.remote_addr,
description = 'Repository {repo} pulled',
metadata = {
'repo': repository,
'namespace': namespace,
'username': pull_username,
'public': is_public
},
repository = repo)
return resp
abort(403)

View file

@ -110,7 +110,37 @@ def initialize_database():
Visibility.create(name='private')
LoginService.create(name='github')
LoginService.create(name='quayrobot')
LogEntryKind.create(name='account_change_plan')
LogEntryKind.create(name='account_change_cc')
LogEntryKind.create(name='account_change_password')
LogEntryKind.create(name='account_convert')
LogEntryKind.create(name='create_robot')
LogEntryKind.create(name='delete_robot')
LogEntryKind.create(name='create_repo')
LogEntryKind.create(name='push_repo')
LogEntryKind.create(name='pull_repo')
LogEntryKind.create(name='delete_repo')
LogEntryKind.create(name='add_repo_permission')
LogEntryKind.create(name='change_repo_permission')
LogEntryKind.create(name='delete_repo_permission')
LogEntryKind.create(name='change_repo_visibility')
LogEntryKind.create(name='add_repo_accesstoken')
LogEntryKind.create(name='delete_repo_accesstoken')
LogEntryKind.create(name='add_repo_webhook')
LogEntryKind.create(name='delete_repo_webhook')
LogEntryKind.create(name='set_repo_description')
LogEntryKind.create(name='build_dockerfile')
LogEntryKind.create(name='org_create_team')
LogEntryKind.create(name='org_delete_team')
LogEntryKind.create(name='org_add_team_member')
LogEntryKind.create(name='org_remove_team_member')
LogEntryKind.create(name='org_set_team_description')
LogEntryKind.create(name='org_set_team_role')
def wipe_database():
logger.debug('Wiping all data from the DB.')

View file

@ -127,6 +127,30 @@ html, body {
margin-right: 10px;
}
.logs-view-element .header {
padding-bottom: 10px;
border-bottom: 1px solid #eee;
margin-bottom: 20px;
}
.logs-view-element .header b {
font-size: 24px;
vertical-align: middle;
}
.logs-view-element .header .right {
float: right;
}
.logs-view-element .log .circle {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.billing-options-element .current-card {
font-size: 16px;
margin-bottom: 20px;
@ -2043,13 +2067,13 @@ p.editable:hover i {
/** D3 tooltip styling */
.d3-tip {
line-height: 1;
padding: 12px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
border-radius: 2px;
max-width: 500px;
.d3-tip, .nvtooltip {
line-height: 1 !important;
padding: 12px !important;
background: rgba(0, 0, 0, 0.8) !important;
color: #fff !important;
border-radius: 2px !important;
max-width: 500px !important;
z-index: 9999999;
}

View file

@ -0,0 +1,50 @@
<div class="logs-view-element">
<div ng-show="loading">
<i class="fa fa-spinner fa-spin fa-3x"></i>
</div>
<div ng-show="!loading">
<div class="container header">
<b>Usage logs for the last seven days</b>
<span class="right">
<button class="btn btn-default btn-toggle" ng-class="chartVisible ? 'active' : ''" ng-click="toggleChart()">
Show Chart
</button>
</span>
</div>
<div id="bar-chart" style="width: 800px; height: 500px;" ng-show="chartVisible">
<svg style="width: 800px; height: 500px;"></svg>
</div>
<div class="side-controls">
<div class="result-count">
Showing {{(logs | visibleLogFilter:kindsAllowed | filter:search | limitTo:150).length}} of
{{(logs | visibleLogFilter:kindsAllowed | filter:search).length}} matching logs
</div>
<div class="filter-input">
<input id="log-filter" class="form-control" placeholder="Filter Logs" type="text" ng-model="search.$">
</div>
</div>
<table class="table">
<thead>
<th>Description</th>
<th>Date/Time</th>
<th>User</th>
</thead>
<tbody>
<tr class="log" ng-repeat="log in (logs | visibleLogFilter:kindsAllowed | filter:search | limitTo:150)">
<td>
<span class="circle" style="{{ 'background: ' + getColor(log.kind) }}"></span>
<span ng-bind-html="getDescription(log)"></span>
</td>
<td>{{ log.datetime }}</td>
<td>
<span class="entity-reference" name="log.performer.username" isrobot="log.performer.is_robot"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View file

@ -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,

View file

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

View file

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

9275
static/lib/d3.js vendored Normal file

File diff suppressed because it is too large Load diff

769
static/lib/nv.d3.css Normal file
View file

@ -0,0 +1,769 @@
/********************
* HTML CSS
*/
.chartWrap {
margin: 0;
padding: 0;
overflow: hidden;
}
/********************
Box shadow and border radius styling
*/
.nvtooltip.with-3d-shadow, .with-3d-shadow .nvtooltip {
-moz-box-shadow: 0 5px 10px rgba(0,0,0,.2);
-webkit-box-shadow: 0 5px 10px rgba(0,0,0,.2);
box-shadow: 0 5px 10px rgba(0,0,0,.2);
-webkit-border-radius: 6px;
-moz-border-radius: 6px;
border-radius: 6px;
}
/********************
* TOOLTIP CSS
*/
.nvtooltip {
position: absolute;
background-color: rgba(255,255,255,1.0);
padding: 1px;
border: 1px solid rgba(0,0,0,.2);
z-index: 10000;
font-family: Arial;
font-size: 13px;
text-align: left;
pointer-events: none;
white-space: nowrap;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/*Give tooltips that old fade in transition by
putting a "with-transitions" class on the container div.
*/
.nvtooltip.with-transitions, .with-transitions .nvtooltip {
transition: opacity 250ms linear;
-moz-transition: opacity 250ms linear;
-webkit-transition: opacity 250ms linear;
transition-delay: 250ms;
-moz-transition-delay: 250ms;
-webkit-transition-delay: 250ms;
}
.nvtooltip.x-nvtooltip,
.nvtooltip.y-nvtooltip {
padding: 8px;
}
.nvtooltip h3 {
margin: 0;
padding: 4px 14px;
line-height: 18px;
font-weight: normal;
background-color: rgba(247,247,247,0.75);
text-align: center;
border-bottom: 1px solid #ebebeb;
-webkit-border-radius: 5px 5px 0 0;
-moz-border-radius: 5px 5px 0 0;
border-radius: 5px 5px 0 0;
}
.nvtooltip p {
margin: 0;
padding: 5px 14px;
text-align: center;
}
.nvtooltip span {
display: inline-block;
margin: 2px 0;
}
.nvtooltip table {
margin: 6px;
border-spacing:0;
}
.nvtooltip table td {
padding: 2px 9px 2px 0;
vertical-align: middle;
}
.nvtooltip table td.key {
font-weight:normal;
}
.nvtooltip table td.value {
text-align: right;
font-weight: bold;
}
.nvtooltip table tr.highlight td {
padding: 1px 9px 1px 0;
border-bottom-style: solid;
border-bottom-width: 1px;
border-top-style: solid;
border-top-width: 1px;
}
.nvtooltip table td.legend-color-guide div {
width: 8px;
height: 8px;
vertical-align: middle;
}
.nvtooltip .footer {
padding: 3px;
text-align: center;
}
.nvtooltip-pending-removal {
position: absolute;
pointer-events: none;
}
/********************
* SVG CSS
*/
svg {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
/* Trying to get SVG to act like a greedy block in all browsers */
display: block;
width:100%;
height:100%;
}
svg text {
font: normal 12px Arial;
}
svg .title {
font: bold 14px Arial;
}
.nvd3 .nv-background {
fill: white;
fill-opacity: 0;
/*
pointer-events: none;
*/
}
.nvd3.nv-noData {
font-size: 18px;
font-weight: bold;
}
/**********
* Brush
*/
.nv-brush .extent {
fill-opacity: .125;
shape-rendering: crispEdges;
}
/**********
* Legend
*/
.nvd3 .nv-legend .nv-series {
cursor: pointer;
}
.nvd3 .nv-legend .disabled circle {
fill-opacity: 0;
}
/**********
* Axes
*/
.nvd3 .nv-axis {
pointer-events:none;
}
.nvd3 .nv-axis path {
fill: none;
stroke: #000;
stroke-opacity: .75;
shape-rendering: crispEdges;
}
.nvd3 .nv-axis path.domain {
stroke-opacity: .75;
}
.nvd3 .nv-axis.nv-x path.domain {
stroke-opacity: 0;
}
.nvd3 .nv-axis line {
fill: none;
stroke: #e5e5e5;
shape-rendering: crispEdges;
}
.nvd3 .nv-axis .zero line,
/*this selector may not be necessary*/ .nvd3 .nv-axis line.zero {
stroke-opacity: .75;
}
.nvd3 .nv-axis .nv-axisMaxMin text {
font-weight: bold;
}
.nvd3 .x .nv-axis .nv-axisMaxMin text,
.nvd3 .x2 .nv-axis .nv-axisMaxMin text,
.nvd3 .x3 .nv-axis .nv-axisMaxMin text {
text-anchor: middle
}
/**********
* Brush
*/
.nv-brush .resize path {
fill: #eee;
stroke: #666;
}
/**********
* Bars
*/
.nvd3 .nv-bars .negative rect {
zfill: brown;
}
.nvd3 .nv-bars rect {
zfill: steelblue;
fill-opacity: .75;
transition: fill-opacity 250ms linear;
-moz-transition: fill-opacity 250ms linear;
-webkit-transition: fill-opacity 250ms linear;
}
.nvd3 .nv-bars rect.hover {
fill-opacity: 1;
}
.nvd3 .nv-bars .hover rect {
fill: lightblue;
}
.nvd3 .nv-bars text {
fill: rgba(0,0,0,0);
}
.nvd3 .nv-bars .hover text {
fill: rgba(0,0,0,1);
}
/**********
* Bars
*/
.nvd3 .nv-multibar .nv-groups rect,
.nvd3 .nv-multibarHorizontal .nv-groups rect,
.nvd3 .nv-discretebar .nv-groups rect {
stroke-opacity: 0;
transition: fill-opacity 250ms linear;
-moz-transition: fill-opacity 250ms linear;
-webkit-transition: fill-opacity 250ms linear;
}
.nvd3 .nv-multibar .nv-groups rect:hover,
.nvd3 .nv-multibarHorizontal .nv-groups rect:hover,
.nvd3 .nv-discretebar .nv-groups rect:hover {
fill-opacity: 1;
}
.nvd3 .nv-discretebar .nv-groups text,
.nvd3 .nv-multibarHorizontal .nv-groups text {
font-weight: bold;
fill: rgba(0,0,0,1);
stroke: rgba(0,0,0,0);
}
/***********
* Pie Chart
*/
.nvd3.nv-pie path {
stroke-opacity: 0;
transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear;
-moz-transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear;
-webkit-transition: fill-opacity 250ms linear, stroke-width 250ms linear, stroke-opacity 250ms linear;
}
.nvd3.nv-pie .nv-slice text {
stroke: #000;
stroke-width: 0;
}
.nvd3.nv-pie path {
stroke: #fff;
stroke-width: 1px;
stroke-opacity: 1;
}
.nvd3.nv-pie .hover path {
fill-opacity: .7;
}
.nvd3.nv-pie .nv-label {
pointer-events: none;
}
.nvd3.nv-pie .nv-label rect {
fill-opacity: 0;
stroke-opacity: 0;
}
/**********
* Lines
*/
.nvd3 .nv-groups path.nv-line {
fill: none;
stroke-width: 1.5px;
/*
stroke-linecap: round;
shape-rendering: geometricPrecision;
transition: stroke-width 250ms linear;
-moz-transition: stroke-width 250ms linear;
-webkit-transition: stroke-width 250ms linear;
transition-delay: 250ms
-moz-transition-delay: 250ms;
-webkit-transition-delay: 250ms;
*/
}
.nvd3 .nv-groups path.nv-line.nv-thin-line {
stroke-width: 1px;
}
.nvd3 .nv-groups path.nv-area {
stroke: none;
/*
stroke-linecap: round;
shape-rendering: geometricPrecision;
stroke-width: 2.5px;
transition: stroke-width 250ms linear;
-moz-transition: stroke-width 250ms linear;
-webkit-transition: stroke-width 250ms linear;
transition-delay: 250ms
-moz-transition-delay: 250ms;
-webkit-transition-delay: 250ms;
*/
}
.nvd3 .nv-line.hover path {
stroke-width: 6px;
}
/*
.nvd3.scatter .groups .point {
fill-opacity: 0.1;
stroke-opacity: 0.1;
}
*/
.nvd3.nv-line .nvd3.nv-scatter .nv-groups .nv-point {
fill-opacity: 0;
stroke-opacity: 0;
}
.nvd3.nv-scatter.nv-single-point .nv-groups .nv-point {
fill-opacity: .5 !important;
stroke-opacity: .5 !important;
}
.with-transitions .nvd3 .nv-groups .nv-point {
transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
-moz-transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
-webkit-transition: stroke-width 250ms linear, stroke-opacity 250ms linear;
}
.nvd3.nv-scatter .nv-groups .nv-point.hover,
.nvd3 .nv-groups .nv-point.hover {
stroke-width: 7px;
fill-opacity: .95 !important;
stroke-opacity: .95 !important;
}
.nvd3 .nv-point-paths path {
stroke: #aaa;
stroke-opacity: 0;
fill: #eee;
fill-opacity: 0;
}
.nvd3 .nv-indexLine {
cursor: ew-resize;
}
/**********
* Distribution
*/
.nvd3 .nv-distribution {
pointer-events: none;
}
/**********
* Scatter
*/
/* **Attempting to remove this for useVoronoi(false), need to see if it's required anywhere
.nvd3 .nv-groups .nv-point {
pointer-events: none;
}
*/
.nvd3 .nv-groups .nv-point.hover {
stroke-width: 20px;
stroke-opacity: .5;
}
.nvd3 .nv-scatter .nv-point.hover {
fill-opacity: 1;
}
/*
.nv-group.hover .nv-point {
fill-opacity: 1;
}
*/
/**********
* Stacked Area
*/
.nvd3.nv-stackedarea path.nv-area {
fill-opacity: .7;
/*
stroke-opacity: .65;
fill-opacity: 1;
*/
stroke-opacity: 0;
transition: fill-opacity 250ms linear, stroke-opacity 250ms linear;
-moz-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear;
-webkit-transition: fill-opacity 250ms linear, stroke-opacity 250ms linear;
/*
transition-delay: 500ms;
-moz-transition-delay: 500ms;
-webkit-transition-delay: 500ms;
*/
}
.nvd3.nv-stackedarea path.nv-area.hover {
fill-opacity: .9;
/*
stroke-opacity: .85;
*/
}
/*
.d3stackedarea .groups path {
stroke-opacity: 0;
}
*/
.nvd3.nv-stackedarea .nv-groups .nv-point {
stroke-opacity: 0;
fill-opacity: 0;
}
/*
.nvd3.nv-stackedarea .nv-groups .nv-point.hover {
stroke-width: 20px;
stroke-opacity: .75;
fill-opacity: 1;
}*/
/**********
* Line Plus Bar
*/
.nvd3.nv-linePlusBar .nv-bar rect {
fill-opacity: .75;
}
.nvd3.nv-linePlusBar .nv-bar rect:hover {
fill-opacity: 1;
}
/**********
* Bullet
*/
.nvd3.nv-bullet { font: 10px sans-serif; }
.nvd3.nv-bullet .nv-measure { fill-opacity: .8; }
.nvd3.nv-bullet .nv-measure:hover { fill-opacity: 1; }
.nvd3.nv-bullet .nv-marker { stroke: #000; stroke-width: 2px; }
.nvd3.nv-bullet .nv-markerTriangle { stroke: #000; fill: #fff; stroke-width: 1.5px; }
.nvd3.nv-bullet .nv-tick line { stroke: #666; stroke-width: .5px; }
.nvd3.nv-bullet .nv-range.nv-s0 { fill: #eee; }
.nvd3.nv-bullet .nv-range.nv-s1 { fill: #ddd; }
.nvd3.nv-bullet .nv-range.nv-s2 { fill: #ccc; }
.nvd3.nv-bullet .nv-title { font-size: 14px; font-weight: bold; }
.nvd3.nv-bullet .nv-subtitle { fill: #999; }
.nvd3.nv-bullet .nv-range {
fill: #bababa;
fill-opacity: .4;
}
.nvd3.nv-bullet .nv-range:hover {
fill-opacity: .7;
}
/**********
* Sparkline
*/
.nvd3.nv-sparkline path {
fill: none;
}
.nvd3.nv-sparklineplus g.nv-hoverValue {
pointer-events: none;
}
.nvd3.nv-sparklineplus .nv-hoverValue line {
stroke: #333;
stroke-width: 1.5px;
}
.nvd3.nv-sparklineplus,
.nvd3.nv-sparklineplus g {
pointer-events: all;
}
.nvd3 .nv-hoverArea {
fill-opacity: 0;
stroke-opacity: 0;
}
.nvd3.nv-sparklineplus .nv-xValue,
.nvd3.nv-sparklineplus .nv-yValue {
/*
stroke: #666;
*/
stroke-width: 0;
font-size: .9em;
font-weight: normal;
}
.nvd3.nv-sparklineplus .nv-yValue {
stroke: #f66;
}
.nvd3.nv-sparklineplus .nv-maxValue {
stroke: #2ca02c;
fill: #2ca02c;
}
.nvd3.nv-sparklineplus .nv-minValue {
stroke: #d62728;
fill: #d62728;
}
.nvd3.nv-sparklineplus .nv-currentValue {
/*
stroke: #444;
fill: #000;
*/
font-weight: bold;
font-size: 1.1em;
}
/**********
* historical stock
*/
.nvd3.nv-ohlcBar .nv-ticks .nv-tick {
stroke-width: 2px;
}
.nvd3.nv-ohlcBar .nv-ticks .nv-tick.hover {
stroke-width: 4px;
}
.nvd3.nv-ohlcBar .nv-ticks .nv-tick.positive {
stroke: #2ca02c;
}
.nvd3.nv-ohlcBar .nv-ticks .nv-tick.negative {
stroke: #d62728;
}
.nvd3.nv-historicalStockChart .nv-axis .nv-axislabel {
font-weight: bold;
}
.nvd3.nv-historicalStockChart .nv-dragTarget {
fill-opacity: 0;
stroke: none;
cursor: move;
}
.nvd3 .nv-brush .extent {
/*
cursor: ew-resize !important;
*/
fill-opacity: 0 !important;
}
.nvd3 .nv-brushBackground rect {
stroke: #000;
stroke-width: .4;
fill: #fff;
fill-opacity: .7;
}
/**********
* Indented Tree
*/
/**
* TODO: the following 3 selectors are based on classes used in the example. I should either make them standard and leave them here, or move to a CSS file not included in the library
*/
.nvd3.nv-indentedtree .name {
margin-left: 5px;
}
.nvd3.nv-indentedtree .clickable {
color: #08C;
cursor: pointer;
}
.nvd3.nv-indentedtree span.clickable:hover {
color: #005580;
text-decoration: underline;
}
.nvd3.nv-indentedtree .nv-childrenCount {
display: inline-block;
margin-left: 5px;
}
.nvd3.nv-indentedtree .nv-treeicon {
cursor: pointer;
/*
cursor: n-resize;
*/
}
.nvd3.nv-indentedtree .nv-treeicon.nv-folded {
cursor: pointer;
/*
cursor: s-resize;
*/
}
/**********
* Parallel Coordinates
*/
.nvd3 .background path {
fill: none;
stroke: #ccc;
stroke-opacity: .4;
shape-rendering: crispEdges;
}
.nvd3 .foreground path {
fill: none;
stroke: steelblue;
stroke-opacity: .7;
}
.nvd3 .brush .extent {
fill-opacity: .3;
stroke: #fff;
shape-rendering: crispEdges;
}
.nvd3 .axis line, .axis path {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.nvd3 .axis text {
text-shadow: 0 1px 0 #fff;
}
/****
Interactive Layer
*/
.nvd3 .nv-interactiveGuideLine {
pointer-events:none;
}
.nvd3 line.nv-guideline {
stroke: #ccc;
}

14346
static/lib/nv.d3.js Normal file

File diff suppressed because it is too large Load diff

6
static/lib/nv.d3.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -14,6 +14,7 @@
<div class="col-md-2">
<ul class="nav nav-pills nav-stacked">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#plan">Plan and Usage</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#logs" ng-click="loadLogs()">Usage Logs</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#members" ng-click="loadMembers()">Members</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#robots">Robot Accounts</a></li>
<li ng-show="hasPaidPlan"><a href="javascript:void(0)" data-toggle="tab" data-target="#billingoptions">Billing</a></li>
@ -34,6 +35,11 @@
<div class="robots-manager" organization="organization"></div>
</div>
<!-- Logs tab -->
<div id="logs" class="tab-pane">
<div class="logs-view" organization="organization" visible="logsShown"></div>
</div>
<!-- Billing Options tab -->
<div id="billingoptions" class="tab-pane">
<div class="billing-options" organization="organization"></div>

View file

@ -14,13 +14,17 @@
{% block added_stylesheets %}
<link rel="stylesheet" href="/static/lib/browser-chrome.css">
<link rel="stylesheet" href="/static/lib/nv.d3.css">
{% endblock %}
{% block added_dependencies %}
<script src="https://checkout.stripe.com/checkout.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.2.1/moment.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/d3/3.3.3/d3.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/d3/3.3.10/d3.min.js"></script>
<script src="static/lib/nv.d3.min.js"></script>
<script src="static/lib/ZeroClipboard.min.js"></script>
<script src="static/lib/d3-tip.js" charset="utf-8"></script>