diff --git a/auth/auth.py b/auth/auth.py index d7dce7568..61c6b9a2c 100644 --- a/auth/auth.py +++ b/auth/auth.py @@ -6,6 +6,7 @@ from datetime import datetime from flask import request, session from flask.ext.principal import identity_changed, Identity from flask.ext.login import current_user +from flask.sessions import SecureCookieSessionInterface, BadSignature from base64 import b64decode import scopes @@ -22,6 +23,9 @@ from util.http import abort logger = logging.getLogger(__name__) +SIGNATURE_PREFIX = 'signature=' + + def _load_user_from_cookie(): if not current_user.is_anonymous(): try: @@ -69,7 +73,7 @@ def _validate_and_apply_oauth_token(token): identity_changed.send(app, identity=new_identity) -def process_basic_auth(auth): +def _process_basic_auth(auth): normalized = [part.strip() for part in auth.split(' ') if part] if normalized[0].lower() != 'basic' or len(normalized) != 2: logger.debug('Invalid basic auth format.') @@ -127,44 +131,41 @@ def process_basic_auth(auth): logger.debug('Basic auth present but could not be validated.') -def process_token(auth): +def generate_signed_token(grants): + ser = SecureCookieSessionInterface().get_signing_serializer(app) + data_to_sign = { + 'grants': grants, + } + + encrypted = ser.dumps(data_to_sign) + return '{0}{1}'.format(SIGNATURE_PREFIX, encrypted) + + +def _process_signed_grant(auth): normalized = [part.strip() for part in auth.split(' ') if part] if normalized[0].lower() != 'token' or len(normalized) != 2: - logger.debug('Not an auth token: %s' % auth) + logger.debug('Not a token: %s', auth) return - token_details = normalized[1].split(',') + if not normalized[1].startswith(SIGNATURE_PREFIX): + logger.debug('Not a signed grant token: %s', auth) + return - if len(token_details) != 1: - logger.warning('Invalid token format: %s' % auth) - abort(401, message='Invalid token format: %(auth)s', issue='invalid-auth-token', auth=auth) - - def safe_get(lst, index, default_value): - try: - return lst[index] - except IndexError: - return default_value - - token_vals = {val[0]: safe_get(val, 1, '') for val in - (detail.split('=') for detail in token_details)} - - if 'signature' not in token_vals: - logger.warning('Token does not contain signature: %s' % auth) - abort(401, message='Token does not contain a valid signature: %(auth)s', - issue='invalid-auth-token', auth=auth) + encrypted = normalized[1][len(SIGNATURE_PREFIX):] + ser = SecureCookieSessionInterface().get_signing_serializer(app) try: - token_data = model.load_token_data(token_vals['signature']) - - except model.InvalidTokenException: - logger.warning('Token could not be validated: %s', token_vals['signature']) - abort(401, message='Token could not be validated: %(auth)s', issue='invalid-auth-token', + token_data = ser.loads(encrypted, max_age=app.config['SIGNED_GRANT_EXPIRATION_SEC']) + except BadSignature: + logger.warning('Signed grant could not be validated: %s', encrypted) + abort(401, message='Signed grant could not be validated: %(auth)s', issue='invalid-auth-token', auth=auth) - logger.debug('Successfully validated token: %s', token_data.code) - set_validated_token(token_data) + logger.debug('Successfully validated signed grant with data: %s', token_data) - identity_changed.send(app, identity=Identity(token_data.code, 'token')) + loaded_identity = Identity(None, 'signed_grant') + loaded_identity.provides.update(token_data['grants']) + identity_changed.send(app, identity=loaded_identity) def process_oauth(func): @@ -192,8 +193,8 @@ def process_auth(func): if auth: logger.debug('Validating auth header: %s' % auth) - process_token(auth) - process_basic_auth(auth) + _process_signed_grant(auth) + _process_basic_auth(auth) else: logger.debug('No auth header.') diff --git a/auth/permissions.py b/auth/permissions.py index 4ee73bdb3..6c015cf7e 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -57,6 +57,14 @@ SCOPE_MAX_USER_ROLES.update({ }) +def repository_read_grant(namespace, repository): + return _RepositoryNeed(namespace, repository, 'read') + + +def repository_write_grant(namespace, repository): + return _RepositoryNeed(namespace, repository, 'write') + + class QuayDeferredPermissionUser(Identity): def __init__(self, uuid, auth_type, scopes): super(QuayDeferredPermissionUser, self).__init__(uuid, auth_type) @@ -226,6 +234,11 @@ class ViewTeamPermission(Permission): team_member, admin_org) +class AlwaysFailPermission(Permission): + def can(self): + return False + + @identity_loaded.connect_via(app) def on_identity_loaded(sender, identity): logger.debug('Identity loaded: %s' % identity) @@ -249,5 +262,8 @@ def on_identity_loaded(sender, identity): logger.debug('Delegate token added permission: {0}'.format(repo_grant)) identity.provides.add(repo_grant) + elif identity.auth_type == 'signed_grant': + logger.debug('Loaded signed grants identity') + else: logger.error('Unknown identity auth type: %s', identity.auth_type) diff --git a/conf/http-base.conf b/conf/http-base.conf index d525b3dd3..8b7ff9e0b 100644 --- a/conf/http-base.conf +++ b/conf/http-base.conf @@ -16,6 +16,11 @@ gzip_types text/plain text/xml text/css text/javascript application/x-javascript application/octet-stream; +map $proxy_protocol_addr $proper_forwarded_for { + "" $proxy_add_x_forwarded_for; + default $proxy_protocol_addr; +} + upstream web_app_server { server unix:/tmp/gunicorn_web.sock fail_timeout=0; } @@ -33,3 +38,4 @@ upstream build_manager_controller_server { upstream build_manager_websocket_server { server localhost:8787; } + diff --git a/conf/nginx-nossl.conf b/conf/nginx-nossl.conf index 13c5d73b2..549f4b4e8 100644 --- a/conf/nginx-nossl.conf +++ b/conf/nginx-nossl.conf @@ -4,7 +4,6 @@ include root-base.conf; http { include http-base.conf; - include rate-limiting.conf; server { diff --git a/conf/nginx.conf b/conf/nginx.conf index 792b58faf..77a78f70e 100644 --- a/conf/nginx.conf +++ b/conf/nginx.conf @@ -4,9 +4,7 @@ include root-base.conf; http { include http-base.conf; - include hosted-http-base.conf; - include rate-limiting.conf; server { @@ -25,8 +23,7 @@ http { server { include proxy-protocol.conf; - - include proxy-server-base.conf; + include server-base.conf; listen 8443 default proxy_protocol; diff --git a/conf/proxy-server-base.conf b/conf/proxy-server-base.conf deleted file mode 100644 index 6230dbfd8..000000000 --- a/conf/proxy-server-base.conf +++ /dev/null @@ -1,87 +0,0 @@ -# vim: ft=nginx - -client_body_temp_path /var/log/nginx/client_body 1 2; -server_name _; - -keepalive_timeout 5; - -if ($args ~ "_escaped_fragment_") { - rewrite ^ /snapshot$uri; -} - -proxy_set_header X-Forwarded-For $proxy_protocol_addr; -proxy_set_header X-Forwarded-Proto $scheme; -proxy_set_header Host $http_host; -proxy_redirect off; - -proxy_set_header Transfer-Encoding $http_transfer_encoding; - -location / { - proxy_pass http://web_app_server; - - limit_req zone=webapp burst=25 nodelay; -} - -location /realtime { - proxy_pass http://web_app_server; - proxy_buffering off; - proxy_request_buffering off; -} - -location /v1/repositories/ { - proxy_buffering off; - - proxy_request_buffering off; - - proxy_pass http://registry_app_server; - proxy_temp_path /var/log/nginx/proxy_temp 1 2; - - client_max_body_size 20G; - - limit_req zone=repositories burst=5 nodelay; -} - -location /v1/ { - proxy_buffering off; - - proxy_request_buffering off; - - proxy_pass http://registry_app_server; - proxy_temp_path /var/log/nginx/proxy_temp 1 2; - - client_max_body_size 20G; -} - -location /c1/ { - proxy_buffering off; - - proxy_request_buffering off; - - proxy_pass http://verbs_app_server; - proxy_temp_path /var/log/nginx/proxy_temp 1 2; - - limit_req zone=api burst=5 nodelay; -} - -location /static/ { - # checks for static file, if not found proxy to app - alias /static/; -} - -location /v1/_ping { - add_header Content-Type text/plain; - add_header X-Docker-Registry-Version 0.6.0; - add_header X-Docker-Registry-Standalone 0; - return 200 'true'; -} - -location ~ ^/b1/controller(/?)(.*) { - proxy_pass http://build_manager_controller_server/$2; -} - -location ~ ^/b1/socket(/?)(.*) { - proxy_pass http://build_manager_websocket_server/$2; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; -} diff --git a/conf/rate-limiting.conf b/conf/rate-limiting.conf index 3e2538510..e25897d82 100644 --- a/conf/rate-limiting.conf +++ b/conf/rate-limiting.conf @@ -1,7 +1,16 @@ # vim: ft=nginx +# Check the Authorization header and, if it is empty, use their proxy protocol +# IP, else use the header as their unique identifier for rate limiting. +# Enterprise users will never be using proxy protocol, thus the value will be +# empty string. This means they will not get rate limited. +map $http_authorization $registry_bucket { + "" $proxy_protocol_addr; + default $http_authorization; +} + limit_req_zone $proxy_protocol_addr zone=webapp:10m rate=25r/s; -limit_req_zone $proxy_protocol_addr zone=repositories:10m rate=1r/s; limit_req_zone $proxy_protocol_addr zone=api:10m rate=1r/s; +limit_req_zone $registry_bucket zone=repositories:10m rate=1r/s; limit_req_status 429; limit_req_log_level warn; diff --git a/conf/server-base.conf b/conf/server-base.conf index 4122a99eb..bdb6b1a33 100644 --- a/conf/server-base.conf +++ b/conf/server-base.conf @@ -3,16 +3,13 @@ client_body_temp_path /var/log/nginx/client_body 1 2; server_name _; -set_real_ip_from 172.17.0.0/16; -real_ip_header X-Forwarded-For; - keepalive_timeout 5; if ($args ~ "_escaped_fragment_") { rewrite ^ /snapshot$uri; } -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-For $proper_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $http_host; proxy_redirect off; @@ -21,6 +18,8 @@ proxy_set_header Transfer-Encoding $http_transfer_encoding; location / { proxy_pass http://web_app_server; + + limit_req zone=webapp; } location /realtime { @@ -29,6 +28,18 @@ location /realtime { proxy_request_buffering off; } +location /v1/repositories/ { + proxy_buffering off; + + proxy_request_buffering off; + + proxy_pass http://registry_app_server; + proxy_read_timeout 2000; + proxy_temp_path /var/log/nginx/proxy_temp 1 2; + + limit_req zone=repositories; +} + location /v1/ { proxy_buffering off; @@ -47,6 +58,8 @@ location /c1/ { proxy_pass http://verbs_app_server; proxy_temp_path /var/log/nginx/proxy_temp 1 2; + + limit_req zone=api; } location /static/ { diff --git a/config.py b/config.py index 4bb44fca2..339ffca34 100644 --- a/config.py +++ b/config.py @@ -197,4 +197,7 @@ class DefaultConfig(object): SYSTEM_SERVICE_BLACKLIST = [] # Temporary tag expiration in seconds, this may actually be longer based on GC policy - PUSH_TEMP_TAG_EXPIRATION_SEC = 60 * 60 + PUSH_TEMP_TAG_EXPIRATION_SEC = 60 * 60 # One hour per layer + + # Signed registry grant token expiration in seconds + SIGNED_GRANT_EXPIRATION_SEC = 60 * 60 * 24 # One day to complete a push/pull diff --git a/endpoints/index.py b/endpoints/index.py index f2e1f7411..a20c492d6 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -9,12 +9,13 @@ from collections import OrderedDict from data import model from data.model import oauth from app import app, authentication, userevents, storage -from auth.auth import process_auth +from auth.auth import process_auth, generate_signed_token from auth.auth_context import get_authenticated_user, get_validated_token, get_validated_oauth_token from util.names import parse_repository_name from util.useremails import send_confirmation_email from auth.permissions import (ModifyRepositoryPermission, UserAdminPermission, - ReadRepositoryPermission, CreateRepositoryPermission) + ReadRepositoryPermission, CreateRepositoryPermission, + AlwaysFailPermission, repository_read_grant, repository_write_grant) from util.http import abort from endpoints.trackhelper import track_and_log @@ -26,7 +27,13 @@ logger = logging.getLogger(__name__) index = Blueprint('index', __name__) -def generate_headers(role='read'): + +class GrantType(object): + READ_REPOSITORY = 'read' + WRITE_REPOSITORY = 'write' + + +def generate_headers(scope=GrantType.READ_REPOSITORY): def decorator_method(f): @wraps(f) def wrapper(namespace, repository, *args, **kwargs): @@ -35,12 +42,6 @@ def generate_headers(role='read'): # Setting session namespace and repository session['namespace'] = namespace session['repository'] = repository - - if get_authenticated_user(): - session['username'] = get_authenticated_user().username - else: - session.pop('username', None) - # We run our index and registry on the same hosts for now registry_server = urlparse.urlparse(request.url).netloc response.headers['X-Docker-Endpoints'] = registry_server @@ -48,16 +49,23 @@ def generate_headers(role='read'): has_token_request = request.headers.get('X-Docker-Token', '') if has_token_request: - repo = model.get_repository(namespace, repository) - if repo: - token = model.create_access_token(repo, role, 'pushpull-token') - token_str = 'signature=%s' % token.code - response.headers['WWW-Authenticate'] = token_str - response.headers['X-Docker-Token'] = token_str - else: - logger.info('Token request in non-existing repo: %s/%s' % - (namespace, repository)) + permission = AlwaysFailPermission() + grants = [] + if scope == GrantType.READ_REPOSITORY: + permission = ReadRepositoryPermission(namespace, repository) + grants.append(repository_read_grant(namespace, repository)) + elif scope == GrantType.WRITE_REPOSITORY: + permission = ModifyRepositoryPermission(namespace, repository) + grants.append(repository_write_grant(namespace, repository)) + if permission.can(): + # Generate a signed grant which expires here + signature = generate_signed_token(grants) + response.headers['WWW-Authenticate'] = signature + response.headers['X-Docker-Token'] = signature + else: + logger.warning('Registry request with invalid credentials on repository: %s/%s', + namespace, repository) return response return wrapper return decorator_method @@ -186,7 +194,7 @@ def update_user(username): @index.route('/repositories/', methods=['PUT']) @process_auth @parse_repository_name -@generate_headers(role='write') +@generate_headers(scope=GrantType.WRITE_REPOSITORY) def create_repository(namespace, repository): logger.debug('Parsing image descriptions') image_descriptions = json.loads(request.data.decode('utf8')) @@ -228,7 +236,7 @@ def create_repository(namespace, repository): @index.route('/repositories//images', methods=['PUT']) @process_auth @parse_repository_name -@generate_headers(role='write') +@generate_headers(scope=GrantType.WRITE_REPOSITORY) def update_images(namespace, repository): permission = ModifyRepositoryPermission(namespace, repository) @@ -273,7 +281,7 @@ def update_images(namespace, repository): @index.route('/repositories//images', methods=['GET']) @process_auth @parse_repository_name -@generate_headers(role='read') +@generate_headers(scope=GrantType.READ_REPOSITORY) def get_repository_images(namespace, repository): permission = ReadRepositoryPermission(namespace, repository) @@ -307,7 +315,7 @@ def get_repository_images(namespace, repository): @index.route('/repositories//images', methods=['DELETE']) @process_auth @parse_repository_name -@generate_headers(role='write') +@generate_headers(scope=GrantType.WRITE_REPOSITORY) def delete_repository_images(namespace, repository): abort(501, 'Not Implemented', issue='not-implemented') diff --git a/endpoints/registry.py b/endpoints/registry.py index c901eed5b..07a33c4d9 100644 --- a/endpoints/registry.py +++ b/endpoints/registry.py @@ -455,14 +455,15 @@ def put_image_json(namespace, repository, image_id): issue='invalid-request', image_id=image_id) logger.debug('Looking up repo image') + + repo = model.get_repository(namespace, repository) + if repo is None: + abort(404, 'Repository does not exist: %(namespace)s/%(repository)s', issue='no-repo', + namespace=namespace, repository=repository) + repo_image = model.get_repo_image_extended(namespace, repository, image_id) if not repo_image: logger.debug('Image not found, creating image') - repo = model.get_repository(namespace, repository) - if repo is None: - abort(404, 'Repository does not exist: %(namespace)s/%(repository)s', issue='no-repo', - namespace=namespace, repository=repository) - username = get_authenticated_user() and get_authenticated_user().username repo_image = model.find_create_or_link_image(image_id, repo, username, {}, store.preferred_locations[0]) diff --git a/endpoints/web.py b/endpoints/web.py index 811a8f94d..c3af01e44 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -173,6 +173,12 @@ def security(): return index('') +@web.route('/__exp/') +@no_cache +def exp(expname): + return index('') + + @web.route('/v1') @web.route('/v1/') @no_cache diff --git a/static/directives/prototype-manager.html b/static/directives/prototype-manager.html index 68aaaffc7..85e897318 100644 --- a/static/directives/prototype-manager.html +++ b/static/directives/prototype-manager.html @@ -1,7 +1,7 @@
-
+
Default permissions provide a means of specifying additional permissions that should be granted automatically to a repository when it is created.
diff --git a/static/js/angular-route-builder.js b/static/js/angular-route-builder.js new file mode 100644 index 000000000..d38ff7eca --- /dev/null +++ b/static/js/angular-route-builder.js @@ -0,0 +1,38 @@ +var AngularRouteBuilder = function(routeProvider, pages, profiles, currentProfile) { + this.routeProvider = routeProvider; + this.pages = pages; + this.profiles = profiles; + + for (var i = 0; i < profiles.length; ++i) { + var current = profiles[i]; + if (current.id == currentProfile) { + this.profiles = this.profiles.slice(i); + break; + } + } +}; + +AngularRouteBuilder.prototype.otherwise = function(options) { + this.routeProvider.otherwise(options); +}; + +AngularRouteBuilder.prototype.route = function(path, pagename) { + // Lookup the page, matching our lists of profiles. + var pair = this.pages.get(pagename, this.profiles); + if (!pair) { + throw Error('Unknown page: ' + pagename); + } + + // Create the route. + var foundProfile = pair[0]; + var page = pair[1]; + var templateUrl = foundProfile.templatePath + page.templateName; + + var options = jQuery.extend({}, page.flags || {}); + options['templateUrl'] = templateUrl; + options['reloadOnSearch'] = false; + options['controller'] = page.controller; + + this.routeProvider.when(path, options); + return this; +}; \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index 32b38d3c4..12adc7521 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2,6881 +2,211 @@ var TEAM_PATTERN = '^[a-zA-Z][a-zA-Z0-9]+$'; var ROBOT_PATTERN = '^[a-zA-Z][a-zA-Z0-9]{3,29}$'; var USER_PATTERN = '^[a-z0-9_]{4,30}$'; -$.fn.clipboardCopy = function() { - if (zeroClipboardSupported) { - (new ZeroClipboard($(this))); - return true; +// Define the pages module. +quayPages = angular.module('quayPages', [], function(){}); + +// Define a constant for creating pages. +quayPages.constant('pages', { + '_pages': {}, + + 'create': function(pageName, templateName, opt_controller, opt_flags, opt_profiles) { + var profiles = opt_profiles || ['old-layout', 'layout']; + for (var i = 0; i < profiles.length; ++i) { + this._pages[profiles[i] + ':' + pageName] = { + 'name': pageName, + 'controller': opt_controller, + 'templateName': templateName, + 'flags': opt_flags || {} + }; + } + }, + + 'get': function(pageName, profiles) { + for (var i = 0; i < profiles.length; ++i) { + var current = profiles[i]; + var key = current.id + ':' + pageName; + var page = this._pages[key]; + if (page) { + return [current, page]; + } + } + + return null; } - - this.hide(); - return false; -}; - -var zeroClipboardSupported = true; -ZeroClipboard.config({ - 'swfPath': 'static/lib/ZeroClipboard.swf' }); -ZeroClipboard.on("error", function(e) { - zeroClipboardSupported = false; -}); - -ZeroClipboard.on('aftercopy', function(e) { - var container = e.target.parentNode.parentNode.parentNode; - var message = $(container).find('.clipboard-copied-message')[0]; - - // Resets the animation. - var elem = message; - elem.style.display = 'none'; - elem.classList.remove('animated'); - - // Show the notification. - setTimeout(function() { - elem.style.display = 'inline-block'; - elem.classList.add('animated'); - }, 10); - - // Reset the notification. - setTimeout(function() { - elem.style.display = 'none'; - }, 5000); -}); - -function getRestUrl(args) { - var url = ''; - for (var i = 0; i < arguments.length; ++i) { - if (i > 0) { - url += '/'; - } - url += encodeURI(arguments[i]) - } - return url; -} - -function clickElement(el){ - // From: http://stackoverflow.com/questions/16802795/click-not-working-in-mocha-phantomjs-on-certain-elements - var ev = document.createEvent("MouseEvent"); - ev.initMouseEvent( - "click", - true /* bubble */, true /* cancelable */, - window, null, - 0, 0, 0, 0, /* coordinates */ - false, false, false, false, /* modifier keys */ - 0 /*left*/, null); - el.dispatchEvent(ev); -} - -function getFirstTextLine(commentString) { - if (!commentString) { return ''; } - - var lines = commentString.split('\n'); - var MARKDOWN_CHARS = { - '#': true, - '-': true, - '>': true, - '`': true - }; - - for (var i = 0; i < lines.length; ++i) { - // Skip code lines. - if (lines[i].indexOf(' ') == 0) { - continue; - } - - // Skip empty lines. - if ($.trim(lines[i]).length == 0) { - continue; - } - - // Skip control lines. - if (MARKDOWN_CHARS[$.trim(lines[i])[0]]) { - continue; - } - - return getMarkedDown(lines[i]); - } - - return ''; -} - -function createRobotAccount(ApiService, is_org, orgname, name, callback) { - ApiService.createRobot(is_org ? orgname : null, null, {'robot_shortname': name}) - .then(callback, ApiService.errorDisplay('Cannot create robot account')); -} - -function createOrganizationTeam(ApiService, orgname, teamname, callback) { - var data = { - 'name': teamname, - 'role': 'member' - }; - - var params = { - 'orgname': orgname, - 'teamname': teamname - }; - - ApiService.updateOrganizationTeam(data, params) - .then(callback, ApiService.errorDisplay('Cannot create team')); -} - -function getMarkedDown(string) { - // This automatically puts text into paragraph tags, which may or may not effect - // the style of a page. For consistency, we make sure empty text is paragraph tags. - var html = Markdown.getSanitizingConverter().makeHtml(string || ''); - return html == '' ? '

easter egg

' : html; -} - - quayDependencies = ['ngRoute', 'chieffancypants.loadingBar', 'angular-tour', 'restangular', 'angularMoment', 'mgcrea.ngStrap', 'ngCookies', 'ngSanitize', 'angular-md5', 'pasvaz.bindonce', 'ansiToHtml', - 'ngAnimate', 'core-ui', 'core-config-setup']; + 'ngAnimate', 'core-ui', 'core-config-setup', 'quayPages']; if (window.__config && window.__config.MIXPANEL_KEY) { quayDependencies.push('angulartics'); quayDependencies.push('angulartics.mixpanel'); } +// Define the application. quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoadingBarProvider) { - cfpLoadingBarProvider.includeSpinner = false; + cfpLoadingBarProvider.includeSpinner = false; +}); - /** - * Specialized wrapper around array which provides a toggle() method for viewing the contents of the - * array in a manner that is asynchronously filled in over a short time period. This prevents long - * pauses in the UI for ngRepeat's when the array is significant in size. - */ - $provide.factory('AngularViewArray', ['$interval', function($interval) { - var ADDTIONAL_COUNT = 20; +// Configure the routes. +quayApp.config(['$routeProvider', '$locationProvider', 'pages', function($routeProvider, $locationProvider, pages) { + var title = window.__config['REGISTRY_TITLE'] || 'Quay.io'; - function _ViewArray() { - this.isVisible = false; - this.visibleEntries = null; - this.hasEntries = false; - this.entries = []; + $locationProvider.html5Mode(true); - this.timerRef_ = null; - this.currentIndex_ = 0; - } + // WARNING WARNING WARNING + // If you add a route here, you must add a corresponding route in thr endpoints/web.py + // index rule to make sure that deep links directly deep into the app continue to work. + // WARNING WARNING WARNING - _ViewArray.prototype.length = function() { - return this.entries.length; - }; + var layoutProfile = 'old-layout'; - _ViewArray.prototype.get = function(index) { - return this.entries[index]; - }; + // Check for the cookie for turning on the new layout. + if (document.cookie.toString().indexOf('quay.exp-new-layout=true') >= 0) { + layoutProfile = 'layout'; + } - _ViewArray.prototype.push = function(elem) { - this.entries.push(elem); - this.hasEntries = true; + // Check for the override flag. + if (window.location.search.indexOf('old-ui=1') >= 0) { + layoutProfile = 'old-layout'; + } - if (this.isVisible) { - this.setVisible(true); - } - }; + window.console.log('Using layout profile: ' + layoutProfile); - _ViewArray.prototype.toggle = function() { - this.setVisible(!this.isVisible); - }; + var routeBuilder = new AngularRouteBuilder($routeProvider, pages, [ + // Start with the old pages (if we asked for it). + {id: 'old-layout', templatePath: '/static/partials/'}, - _ViewArray.prototype.setVisible = function(newState) { - this.isVisible = newState; + // Fallback back combined new/existing pages. + {id: 'layout', templatePath: '/static/partials/'} + ], layoutProfile); - this.visibleEntries = []; - this.currentIndex_ = 0; + routeBuilder + // Repository View + .route('/repository/:namespace/:name', 'repo-view') + .route('/repository/:namespace/:name/tag/:tag', 'repo-view') - if (newState) { - this.showAdditionalEntries_(); - this.startTimer_(); - } else { - this.stopTimer_(); - } - }; + // Image View + .route('/repository/:namespace/:name/image/:image', 'image-view') - _ViewArray.prototype.showAdditionalEntries_ = function() { - var i = 0; - for (i = this.currentIndex_; i < (this.currentIndex_ + ADDTIONAL_COUNT) && i < this.entries.length; ++i) { - this.visibleEntries.push(this.entries[i]); - } + // Repo Admin + .route('/repository/:namespace/:name/admin', 'repo-admin') - this.currentIndex_ = i; - if (this.currentIndex_ >= this.entries.length) { - this.stopTimer_(); - } - }; + // Repo Builds + .route('/repository/:namespace/:name/build', 'repo-build') - _ViewArray.prototype.startTimer_ = function() { - var that = this; - this.timerRef_ = $interval(function() { - that.showAdditionalEntries_(); - }, 10); - }; + // Repo Build Package + .route('/repository/:namespace/:name/build/:buildid/buildpack', 'build-package') - _ViewArray.prototype.stopTimer_ = function() { - if (this.timerRef_) { - $interval.cancel(this.timerRef_); - this.timerRef_ = null; - } - }; + // Repo List + .route('/repository/', 'repo-list') - var service = { - 'create': function() { - return new _ViewArray(); - } - }; + // Organizations + .route('/organizations/', 'organizations') - return service; - }]); + // New Organization + .route('/organizations/new/', 'new-organization') - /** - * Specialized class for conducting an HTTP poll, while properly preventing multiple calls. - */ - $provide.factory('AngularPollChannel', ['ApiService', '$timeout', function(ApiService, $timeout) { - var _PollChannel = function(scope, requester, opt_sleeptime) { - this.scope_ = scope; - this.requester_ = requester; - this.sleeptime_ = opt_sleeptime || (60 * 1000 /* 60s */); - this.timer_ = null; + // View Organization + .route('/organization/:orgname', 'org-view') - this.working = false; - this.polling = false; + // Organization Admin + .route('/organization/:orgname/admin', 'org-admin') - var that = this; - scope.$on('$destroy', function() { - that.stop(); - }); - }; + // View Organization Team + .route('/organization/:orgname/teams/:teamname', 'team-view') - _PollChannel.prototype.stop = function() { - if (this.timer_) { - $timeout.cancel(this.timer_); - this.timer_ = null; - this.polling_ = false; - } + // Organization Member Logs + .route('/organization/:orgname/logs/:membername', 'org-member-logs') - this.working = false; - }; + // Organization View Application + .route('/organization/:orgname/application/:clientid', 'manage-application') - _PollChannel.prototype.start = function() { - // Make sure we invoke call outside the normal digest cycle, since - // we'll call $scope.$apply ourselves. - var that = this; - setTimeout(function() { that.call_(); }, 0); - }; + // User Admin + .route('/user/', 'user-admin') - _PollChannel.prototype.call_ = function() { - if (this.working) { return; } + // Sign In + .route('/signin/', 'signin') - var that = this; - this.working = true; - this.scope_.$apply(function() { - that.requester_(function(status) { - if (status) { - that.working = false; - that.setupTimer_(); - } else { - that.stop(); - } - }); - }); - }; + // New Repository + .route('/new/', 'new-repo') - _PollChannel.prototype.setupTimer_ = function() { - if (this.timer_) { return; } + // ER Management + .route('/superuser/', 'superuser') - var that = this; - this.polling = true; - this.timer_ = $timeout(function() { - that.timer_ = null; - that.call_(); - }, this.sleeptime_) - }; + // ER Setup + .route('/setup/', 'setup') - var service = { - 'create': function(scope, requester, opt_sleeptime) { - return new _PollChannel(scope, requester, opt_sleeptime); - } - }; + // Plans + .route('/plans/', 'plans') - return service; - }]); + // Tutorial + .route('/tutorial/', 'tutorial') - $provide.factory('DataFileService', [function() { - var dataFileService = {}; + // Contact + .route('/contact/', 'contact') - dataFileService.getName_ = function(filePath) { - var parts = filePath.split('/'); - return parts[parts.length - 1]; - }; + // About + .route('/about/', 'about') - dataFileService.tryAsZip_ = function(buf, success, failure) { - var zip = null; - var zipFiles = null; - try { - var zip = new JSZip(buf); - zipFiles = zip.files; - } catch (e) { - failure(); - return; - } + // Security + .route('/security/', 'security') - var files = []; - for (var filePath in zipFiles) { - if (zipFiles.hasOwnProperty(filePath)) { - files.push({ - 'name': dataFileService.getName_(filePath), - 'path': filePath, - 'canRead': true, - 'toBlob': (function(fp) { - return function() { - return new Blob([zip.file(fp).asArrayBuffer()]); - }; - }(filePath)) - }); - } - } + // Landing Page + .route('/', 'landing') - success(files); - }; + // Tour + .route('/tour/', 'tour') + .route('/tour/features', 'tour') + .route('/tour/organizations', 'tour') + .route('/tour/enterprise', 'tour') - dataFileService.tryAsTarGz_ = function(buf, success, failure) { - var gunzip = new Zlib.Gunzip(buf); - var plain = null; + // Confirm Invite + .route('/confirminvite', 'confirm-invite') - try { - plain = gunzip.decompress(); - } catch (e) { - failure(); - return; - } + // Enable/disable experimental layout + .route('/__exp/newlayout', 'exp-new-layout') - dataFileService.tryAsTar_(plain, success, failure); - }; + // Default: Redirect to the landing page + .otherwise({redirectTo: '/'}); +}]); - dataFileService.tryAsTar_ = function(buf, success, failure) { - var collapsePath = function(originalPath) { - // Tar files can contain entries of the form './', so we need to collapse - // those paths down. - var parts = originalPath.split('/'); - for (var i = parts.length - 1; i >= 0; i--) { - var part = parts[i]; - if (part == '.') { - parts.splice(i, 1); - } - } - return parts.join('/'); - }; +// Configure compile provider to add additional URL prefixes to the sanitization list. We use +// these on the Contact page. +quayApp.config(function($compileProvider) { + $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|ftp|mailto|tel|irc):/); +}); - var handler = new Untar(buf); - handler.process(function(status, read, files, err) { - switch (status) { - case 'error': - failure(err); - break; +// Configure the API provider. +quayApp.config(function(RestangularProvider) { + RestangularProvider.setBaseUrl('/api/v1/'); +}); - case 'done': - var processed = []; - for (var i = 0; i < files.length; ++i) { - var currentFile = files[i]; - var path = collapsePath(currentFile.meta.filename); - - if (path == '' || path == 'pax_global_header') { continue; } - - processed.push({ - 'name': dataFileService.getName_(path), - 'path': path, - 'canRead': true, - 'toBlob': (function(currentFile) { - return function() { - return new Blob([currentFile.buffer], {type: 'application/octet-binary'}); - }; - }(currentFile)) - }); - } - success(processed); - break; - } - }); - }; - - dataFileService.blobToString = function(blob, callback) { - var reader = new FileReader(); - reader.onload = function(event){ - callback(reader.result); - }; - reader.readAsText(blob); - }; - - dataFileService.arrayToString = function(buf, callback) { - var bb = new Blob([buf], {type: 'application/octet-binary'}); - var f = new FileReader(); - f.onload = function(e) { - callback(e.target.result); - }; - f.onerror = function(e) { - callback(null); - }; - f.onabort = function(e) { - callback(null); - }; - f.readAsText(bb); - }; - - dataFileService.readDataArrayAsPossibleArchive = function(buf, success, failure) { - dataFileService.tryAsZip_(buf, success, function() { - dataFileService.tryAsTarGz_(buf, success, failure); - }); - }; - - dataFileService.downloadDataFileAsArrayBuffer = function($scope, url, progress, error, loaded) { - var request = new XMLHttpRequest(); - request.open('GET', url, true); - request.responseType = 'arraybuffer'; - - request.onprogress = function(e) { - $scope.$apply(function() { - var percentLoaded; - if (e.lengthComputable) { - progress(e.loaded / e.total); - } - }); - }; - - request.onerror = function() { - $scope.$apply(function() { - error(); - }); - }; - - request.onload = function() { - if (this.status == 200) { - $scope.$apply(function() { - var uint8array = new Uint8Array(request.response); - loaded(uint8array); - }); - return; - } - }; - - request.send(); - }; - - return dataFileService; - }]); - - - $provide.factory('UIService', [function() { - var uiService = {}; - - uiService.hidePopover = function(elem) { - var popover = $(elem).data('bs.popover'); - if (popover) { - popover.hide(); - } - }; - - uiService.showPopover = function(elem, content) { - var popover = $(elem).data('bs.popover'); - if (!popover) { - $(elem).popover({'content': '-', 'placement': 'left'}); - } - - setTimeout(function() { - var popover = $(elem).data('bs.popover'); - popover.options.content = content; - popover.show(); - }, 500); - }; - - uiService.showFormError = function(elem, result) { - var message = result.data['message'] || result.data['error_description'] || ''; - if (message) { - uiService.showPopover(elem, message); - } else { - uiService.hidePopover(elem); - } - }; - - return uiService; - }]); - - - $provide.factory('UtilService', ['$sanitize', function($sanitize) { - var utilService = {}; - - utilService.isEmailAddress = function(val) { - var emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; - return emailRegex.test(val); - }; - - utilService.escapeHtmlString = function(text) { - var adjusted = text.replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - - return adjusted; - }; - - utilService.textToSafeHtml = function(text) { - return $sanitize(utilService.escapeHtmlString(text)); - }; - - return utilService; - }]); - - - $provide.factory('PingService', [function() { - var pingService = {}; - var pingCache = {}; - - var invokeCallback = function($scope, pings, callback) { - if (pings[0] == -1) { - setTimeout(function() { - $scope.$apply(function() { - callback(-1, false, -1); - }); - }, 0); - return; - } - - var sum = 0; - for (var i = 0; i < pings.length; ++i) { - sum += pings[i]; - } - - // Report the average ping. - setTimeout(function() { - $scope.$apply(function() { - callback(Math.floor(sum / pings.length), true, pings.length); - }); - }, 0); - }; - - var reportPingResult = function($scope, url, ping, callback) { - // Lookup the cached ping data, if any. - var cached = pingCache[url]; - if (!cached) { - cached = pingCache[url] = { - 'pings': [] - }; - } - - // If an error occurred, report it and done. - if (ping < 0) { - cached['pings'] = [-1]; - invokeCallback($scope, [-1], callback); - return; - } - - // Otherwise, add the current ping and determine the average. - cached['pings'].push(ping); - - // Invoke the callback. - invokeCallback($scope, cached['pings'], callback); - - // Schedule another check if we've done less than three. - if (cached['pings'].length < 3) { - setTimeout(function() { - pingUrlInternal($scope, url, callback); - }, 1000); - } - }; - - var pingUrlInternal = function($scope, url, callback) { - var path = url + '?cb=' + (Math.random() * 100); - var start = new Date(); - var xhr = new XMLHttpRequest(); - xhr.onerror = function() { - reportPingResult($scope, url, -1, callback); - }; - - xhr.onreadystatechange = function () { - if (xhr.readyState === xhr.HEADERS_RECEIVED) { - if (xhr.status != 200) { - reportPingResult($scope, url, -1, callback); - return; - } - - var ping = (new Date() - start); - reportPingResult($scope, url, ping, callback); - } - }; - - xhr.open("GET", path); - xhr.send(null); - }; - - pingService.pingUrl = function($scope, url, callback) { - if (pingCache[url]) { - invokeCallback($scope, pingCache[url]['pings'], callback); - return; - } - - // Note: We do each in a callback after 1s to prevent it running when other code - // runs (which can skew the results). - setTimeout(function() { - pingUrlInternal($scope, url, callback); - }, 1000); - }; - - return pingService; - }]); - - $provide.factory('AvatarService', ['Config', '$sanitize', 'md5', - function(Config, $sanitize, md5) { - var avatarService = {}; - var cache = {}; - - avatarService.getAvatar = function(hash, opt_size) { - var size = opt_size || 16; - switch (Config['AVATAR_KIND']) { - case 'local': - return '/avatar/' + hash + '?size=' + size; - break; - - case 'gravatar': - return '//www.gravatar.com/avatar/' + hash + '?d=identicon&size=' + size; - break; - } - }; - - avatarService.computeHash = function(opt_email, opt_name) { - var email = opt_email || ''; - var name = opt_name || ''; - - var cacheKey = email + ':' + name; - if (!cacheKey) { return '-'; } - - if (cache[cacheKey]) { - return cache[cacheKey]; - } - - var hash = md5.createHash(email.toString().toLowerCase()); - switch (Config['AVATAR_KIND']) { - case 'local': - if (name) { - hash = name[0] + hash; - } else if (email) { - hash = email[0] + hash; - } - break; - } - - return cache[cacheKey] = hash; - }; - - return avatarService; - }]); - - $provide.factory('TriggerService', ['UtilService', '$sanitize', 'KeyService', - function(UtilService, $sanitize, KeyService) { - var triggerService = {}; - - var triggerTypes = { - 'github': { - 'description': function(config) { - var source = UtilService.textToSafeHtml(config['build_source']); - var desc = ' Push to Github Repository '; - desc += '' + source + ''; - desc += '
Dockerfile folder: //' + UtilService.textToSafeHtml(config['subdir']); - return desc; - }, - - 'run_parameters': [ - { - 'title': 'Branch', - 'type': 'option', - 'name': 'branch_name' - } - ], - - 'get_redirect_url': function(namespace, repository) { - var redirect_uri = KeyService['githubRedirectUri'] + '/trigger/' + - namespace + '/' + repository; - - var authorize_url = KeyService['githubTriggerAuthorizeUrl']; - var client_id = KeyService['githubTriggerClientId']; - - return authorize_url + 'client_id=' + client_id + - '&scope=repo,user:email&redirect_uri=' + redirect_uri; - } - } - } - - triggerService.getRedirectUrl = function(name, namespace, repository) { - var type = triggerTypes[name]; - if (!type) { - return ''; - } - return type['get_redirect_url'](namespace, repository); - }; - - triggerService.getDescription = function(name, config) { - var type = triggerTypes[name]; - if (!type) { - return 'Unknown'; - } - return type['description'](config); - }; - - triggerService.getRunParameters = function(name, config) { - var type = triggerTypes[name]; - if (!type) { - return []; - } - return type['run_parameters']; - } - - return triggerService; - }]); - - $provide.factory('StringBuilderService', ['$sce', 'UtilService', function($sce, UtilService) { - var stringBuilderService = {}; - - stringBuilderService.buildUrl = function(value_or_func, metadata) { - var url = value_or_func; - if (typeof url != 'string') { - url = url(metadata); - } - - // Find the variables to be replaced. - var varNames = []; - for (var i = 0; i < url.length; ++i) { - var c = url[i]; - if (c == '{') { - for (var j = i + 1; j < url.length; ++j) { - var d = url[j]; - if (d == '}') { - varNames.push(url.substring(i + 1, j)); - i = j; - break; - } - } - } - } - - // Replace all variables found. - for (var i = 0; i < varNames.length; ++i) { - var varName = varNames[i]; - if (!metadata[varName]) { - return null; - } - - url = url.replace('{' + varName + '}', metadata[varName]); - } - - return url; - }; - - stringBuilderService.buildString = function(value_or_func, metadata) { - var fieldIcons = { - 'inviter': 'user', - 'username': 'user', - 'user': 'user', - 'email': 'envelope', - 'activating_username': 'user', - 'delegate_user': 'user', - 'delegate_team': 'group', - 'team': 'group', - 'token': 'key', - 'repo': 'hdd-o', - 'robot': 'wrench', - 'tag': 'tag', - 'role': 'th-large', - 'original_role': 'th-large', - 'application_name': 'cloud', - 'image': 'archive', - 'original_image': 'archive', - 'client_id': 'chain' - }; - - var filters = { - 'obj': function(value) { - if (!value) { return []; } - return Object.getOwnPropertyNames(value); - }, - - 'updated_tags': function(value) { - if (!value) { return []; } - return Object.getOwnPropertyNames(value); - } - }; - - var description = value_or_func; - if (typeof description != 'string') { - description = description(metadata); - } - - for (var key in metadata) { - if (metadata.hasOwnProperty(key)) { - var value = metadata[key] != null ? metadata[key] : '(Unknown)'; - if (filters[key]) { - value = filters[key](value); - } - - if (Array.isArray(value)) { - value = value.join(', '); - } - - value = value.toString(); - - if (key.indexOf('image') >= 0) { - value = value.substr(0, 12); - } - - var safe = UtilService.escapeHtmlString(value); - var markedDown = getMarkedDown(value); - markedDown = markedDown.substr('

'.length, markedDown.length - '

'.length); - - var icon = fieldIcons[key]; - if (icon) { - markedDown = '' + markedDown; - } - - description = description.replace('{' + key + '}', '' + markedDown + ''); - } - } - return $sce.trustAsHtml(description.replace('\n', '
')); - }; - - return stringBuilderService; - }]); - - - $provide.factory('ImageMetadataService', ['UtilService', function(UtilService) { - var metadataService = {}; - metadataService.getFormattedCommand = function(image) { - if (!image || !image.command || !image.command.length) { - return ''; - } - - var getCommandStr = function(command) { - // Handle /bin/sh commands specially. - if (command.length > 2 && command[0] == '/bin/sh' && command[1] == '-c') { - return command[2]; - } - - return command.join(' '); - }; - - return getCommandStr(image.command); - }; - - metadataService.getEscapedFormattedCommand = function(image) { - return UtilService.textToSafeHtml(metadataService.getFormattedCommand(image)); - }; - - return metadataService; - }]); - - $provide.factory('Features', [function() { - if (!window.__features) { - return {}; - } - - var features = window.__features; - features.getFeature = function(name, opt_defaultValue) { - var value = features[name]; - if (value == null) { - return opt_defaultValue; - } - return value; - }; - - features.hasFeature = function(name) { - return !!features.getFeature(name); - }; - - features.matchesFeatures = function(list) { - for (var i = 0; i < list.length; ++i) { - var value = features.getFeature(list[i]); - if (!value) { - return false; - } - } - return true; - }; - - return features; - }]); - - $provide.factory('Config', [function() { - if (!window.__config) { - return {}; - } - - var config = window.__config; - config.getDomain = function() { - return config['SERVER_HOSTNAME']; - }; - - config.getHost = function(opt_auth) { - var auth = opt_auth; - if (auth) { - auth = auth + '@'; - } - - return config['PREFERRED_URL_SCHEME'] + '://' + auth + config['SERVER_HOSTNAME']; - }; - - config.getUrl = function(opt_path) { - var path = opt_path || ''; - return config['PREFERRED_URL_SCHEME'] + '://' + config['SERVER_HOSTNAME'] + path; - }; - - config.getValue = function(name, opt_defaultValue) { - var value = config[name]; - if (value == null) { - return opt_defaultValue; - } - return value; - }; - - return config; - }]); - - $provide.factory('ApiService', ['Restangular', '$q', function(Restangular, $q) { - var apiService = {}; - - var getResource = function(path, opt_background) { - var resource = {}; - resource.url = path; - resource.withOptions = function(options) { - this.options = options; - return this; - }; - - resource.get = function(processor, opt_errorHandler) { - var options = this.options; - var performer = Restangular.one(this.url); - - var result = { - 'loading': true, - 'value': null, - 'hasError': false - }; - - if (opt_background) { - performer.withHttpConfig({ - 'ignoreLoadingBar': true - }); - } - - performer.get(options).then(function(resp) { - result.value = processor(resp); - result.loading = false; - }, function(resp) { - result.hasError = true; - result.loading = false; - if (opt_errorHandler) { - opt_errorHandler(resp); - } - }); - - return result; - }; - - return resource; - }; - - var buildUrl = function(path, parameters, opt_forcessl) { - // We already have /api/v1/ on the URLs, so remove them from the paths. - path = path.substr('/api/v1/'.length, path.length); - - // Build the path, adjusted with the inline parameters. - var used = {}; - var url = ''; - for (var i = 0; i < path.length; ++i) { - var c = path[i]; - if (c == '{') { - var end = path.indexOf('}', i); - var varName = path.substr(i + 1, end - i - 1); - - if (!parameters[varName]) { - throw new Error('Missing parameter: ' + varName); - } - - used[varName] = true; - url += parameters[varName]; - i = end; - continue; - } - - url += c; - } - - // Append any query parameters. - var isFirst = true; - for (var paramName in parameters) { - if (!parameters.hasOwnProperty(paramName)) { continue; } - if (used[paramName]) { continue; } - - var value = parameters[paramName]; - if (value) { - url += isFirst ? '?' : '&'; - url += paramName + '=' + encodeURIComponent(value) - isFirst = false; - } - } - - // If we are forcing SSL, return an absolutel URL with an SSL prefix. - if (opt_forcessl) { - path = 'https://' + window.location.host + '/api/v1/' + path; - } - - return url; - }; - - var getGenericOperationName = function(userOperationName) { - return userOperationName.replace('User', ''); - }; - - var getMatchingUserOperationName = function(orgOperationName, method, userRelatedResource) { - if (userRelatedResource) { - var operations = userRelatedResource['operations']; - for (var i = 0; i < operations.length; ++i) { - var operation = operations[i]; - if (operation['method'].toLowerCase() == method) { - return operation['nickname']; - } - } - } - - throw new Error('Could not find user operation matching org operation: ' + orgOperationName); - }; - - var buildMethodsForEndpointResource = function(endpointResource, resourceMap) { - var name = endpointResource['name']; - var operations = endpointResource['operations']; - for (var i = 0; i < operations.length; ++i) { - var operation = operations[i]; - buildMethodsForOperation(operation, endpointResource, resourceMap); - } - }; - - var freshLoginInProgress = []; - var reject = function(msg) { - for (var i = 0; i < freshLoginInProgress.length; ++i) { - freshLoginInProgress[i].deferred.reject({'data': {'message': msg}}); - } - freshLoginInProgress = []; - }; - - var retry = function() { - for (var i = 0; i < freshLoginInProgress.length; ++i) { - freshLoginInProgress[i].retry(); - } - freshLoginInProgress = []; - }; - - var freshLoginFailCheck = function(opName, opArgs) { - return function(resp) { - var deferred = $q.defer(); - - // If the error is a fresh login required, show the dialog. - if (resp.status == 401 && resp.data['error_type'] == 'fresh_login_required') { - var retryOperation = function() { - apiService[opName].apply(apiService, opArgs).then(function(resp) { - deferred.resolve(resp); - }, function(resp) { - deferred.reject(resp); - }); - }; - - var verifyNow = function() { - var info = { - 'password': $('#freshPassword').val() - }; - - $('#freshPassword').val(''); - - // Conduct the sign in of the user. - apiService.verifyUser(info).then(function() { - // On success, retry the operations. if it succeeds, then resolve the - // deferred promise with the result. Otherwise, reject the same. - retry(); - }, function(resp) { - // Reject with the sign in error. - reject('Invalid verification credentials'); - }); - }; - - // Add the retry call to the in progress list. If there is more than a single - // in progress call, we skip showing the dialog (since it has already been - // shown). - freshLoginInProgress.push({ - 'deferred': deferred, - 'retry': retryOperation - }) - - if (freshLoginInProgress.length > 1) { - return deferred.promise; - } - - var box = bootbox.dialog({ - "message": 'It has been more than a few minutes since you last logged in, ' + - 'so please verify your password to perform this sensitive operation:' + - '
' + - '' + - '
', - "title": 'Please Verify', - "buttons": { - "verify": { - "label": "Verify", - "className": "btn-success", - "callback": verifyNow - }, - "close": { - "label": "Cancel", - "className": "btn-default", - "callback": function() { - reject('Verification canceled') - } - } - } - }); - - box.bind('shown.bs.modal', function(){ - box.find("input").focus(); - box.find("form").submit(function() { - if (!$('#freshPassword').val()) { return; } - - box.modal('hide'); - verifyNow(); - }); - }); - - // Return a new promise. We'll accept or reject it based on the result - // of the login. - return deferred.promise; - } - - // Otherwise, we just 'raise' the error via the reject method on the promise. - return $q.reject(resp); - }; - }; - - var buildMethodsForOperation = function(operation, resource, resourceMap) { - var method = operation['method'].toLowerCase(); - var operationName = operation['nickname']; - var path = resource['path']; - - // Add the operation itself. - apiService[operationName] = function(opt_options, opt_parameters, opt_background, opt_forcessl) { - var one = Restangular.one(buildUrl(path, opt_parameters, opt_forcessl)); - if (opt_background) { - one.withHttpConfig({ - 'ignoreLoadingBar': true - }); - } - - var opObj = one['custom' + method.toUpperCase()](opt_options); - - // If the operation requires_fresh_login, then add a specialized error handler that - // will defer the operation's result if sudo is requested. - if (operation['requires_fresh_login']) { - opObj = opObj.catch(freshLoginFailCheck(operationName, arguments)); - } - return opObj; - }; - - // If the method for the operation is a GET, add an operationAsResource method. - if (method == 'get') { - apiService[operationName + 'AsResource'] = function(opt_parameters, opt_background) { - return getResource(buildUrl(path, opt_parameters), opt_background); - }; - } - - // If the resource has a user-related resource, then make a generic operation for this operation - // that can call both the user and the organization versions of the operation, depending on the - // parameters given. - if (resource['quayUserRelated']) { - var userOperationName = getMatchingUserOperationName(operationName, method, resourceMap[resource['quayUserRelated']]); - var genericOperationName = getGenericOperationName(userOperationName); - apiService[genericOperationName] = function(orgname, opt_options, opt_parameters, opt_background) { - if (orgname) { - if (orgname.name) { - orgname = orgname.name; - } - - var params = jQuery.extend({'orgname' : orgname}, opt_parameters || {}, opt_background); - return apiService[operationName](opt_options, params); - } else { - return apiService[userOperationName](opt_options, opt_parameters, opt_background); - } - }; - } - }; - - if (!window.__endpoints) { - return apiService; - } - - var resourceMap = {}; - - // Build the map of resource names to their objects. - for (var i = 0; i < window.__endpoints.length; ++i) { - var endpointResource = window.__endpoints[i]; - resourceMap[endpointResource['name']] = endpointResource; - } - - // Construct the methods for each API endpoint. - for (var i = 0; i < window.__endpoints.length; ++i) { - var endpointResource = window.__endpoints[i]; - buildMethodsForEndpointResource(endpointResource, resourceMap); - } - - apiService.getErrorMessage = function(resp, defaultMessage) { - var message = defaultMessage; - if (resp['data']) { - message = resp['data']['error_message'] || resp['data']['message'] || resp['data']['error_description'] || message; - } - - return message; - }; - - apiService.errorDisplay = function(defaultMessage, opt_handler) { - return function(resp) { - var message = apiService.getErrorMessage(resp, defaultMessage); - if (opt_handler) { - var handlerMessage = opt_handler(resp); - if (handlerMessage) { - message = handlerMessage; - } - } - - bootbox.dialog({ - "message": message, - "title": defaultMessage, - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }; - }; - - return apiService; - }]); - - $provide.factory('CookieService', ['$cookies', '$cookieStore', function($cookies, $cookieStore) { - var cookieService = {}; - cookieService.putPermanent = function(name, value) { - document.cookie = escape(name) + "=" + escape(value) + "; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/"; - }; - - cookieService.putSession = function(name, value) { - $cookies[name] = value; - }; - - cookieService.clear = function(name) { - $cookies[name] = ''; - }; - - cookieService.get = function(name) { - return $cookies[name]; - }; - - return cookieService; - }]); - - $provide.factory('ContainerService', ['ApiService', '$timeout', - function(ApiService, $timeout) { - var containerService = {}; - containerService.restartContainer = function(callback) { - ApiService.scShutdownContainer(null, null).then(function(resp) { - $timeout(callback, 2000); - }, ApiService.errorDisplay('Cannot restart container. Please report this to support.')) - }; - - containerService.scheduleStatusCheck = function(callback) { - $timeout(function() { - containerService.checkStatus(callback); - }, 2000); - }; - - containerService.checkStatus = function(callback, force_ssl) { - var errorHandler = function(resp) { - if (resp.status == 404 || resp.status == 502) { - // Container has not yet come back up, so we schedule another check. - containerService.scheduleStatusCheck(callback); - return; - } - - return ApiService.errorDisplay('Cannot load status. Please report this to support')(resp); - }; - - ApiService.scRegistryStatus(null, null) - .then(callback, errorHandler, /* background */true, /* force ssl*/force_ssl); - }; - - return containerService; - }]); - - $provide.factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config', - function(ApiService, CookieService, $rootScope, Config) { - var userResponse = { - verified: false, - anonymous: true, - username: null, - email: null, - organizations: [], - logins: [] - } - - var userService = {} - - userService.hasEverLoggedIn = function() { - return CookieService.get('quay.loggedin') == 'true'; - }; - - userService.updateUserIn = function(scope, opt_callback) { - scope.$watch(function () { return userService.currentUser(); }, function (currentUser) { - scope.user = currentUser; - if (opt_callback) { - opt_callback(currentUser); - } - }, true); - }; - - userService.load = function(opt_callback) { - var handleUserResponse = function(loadedUser) { - userResponse = loadedUser; - - if (!userResponse.anonymous) { - if (Config.MIXPANEL_KEY) { - mixpanel.identify(userResponse.username); - mixpanel.people.set({ - '$email': userResponse.email, - '$username': userResponse.username, - 'verified': userResponse.verified - }); - mixpanel.people.set_once({ - '$created': new Date() - }) - } - - if (window.olark !== undefined) { - olark('api.visitor.getDetails', function(details) { - if (details.fullName === null) { - olark('api.visitor.updateFullName', {fullName: userResponse.username}); - } - }); - olark('api.visitor.updateEmailAddress', {emailAddress: userResponse.email}); - olark('api.chat.updateVisitorStatus', {snippet: 'username: ' + userResponse.username}); - } - - if (window.Raven !== undefined) { - Raven.setUser({ - email: userResponse.email, - id: userResponse.username - }); - } - - CookieService.putPermanent('quay.loggedin', 'true'); - } else { - if (window.Raven !== undefined) { - Raven.setUser(); - } - } - - if (opt_callback) { - opt_callback(); - } - }; - - ApiService.getLoggedInUser().then(function(loadedUser) { - handleUserResponse(loadedUser); - }, function() { - handleUserResponse({'anonymous': true}); - }); - }; - - userService.getOrganization = function(name) { - if (!userResponse || !userResponse.organizations) { return null; } - for (var i = 0; i < userResponse.organizations.length; ++i) { - var org = userResponse.organizations[i]; - if (org.name == name) { - return org; - } - } - - return null; - }; - - userService.isNamespaceAdmin = function(namespace) { - if (namespace == userResponse.username) { - return true; - } - - var org = userService.getOrganization(namespace); - if (!org) { - return false; - } - - return org.is_org_admin; - }; - - userService.isKnownNamespace = function(namespace) { - if (namespace == userResponse.username) { - return true; - } - - var org = userService.getOrganization(namespace); - return !!org; - }; - - userService.currentUser = function() { - return userResponse; - }; - - // Update the user in the root scope. - userService.updateUserIn($rootScope); - - // Load the user the first time. - userService.load(); - - return userService; - }]); - - $provide.factory('ExternalNotificationData', ['Config', 'Features', function(Config, Features) { - var externalNotificationData = {}; - - var events = [ - { - 'id': 'repo_push', - 'title': 'Push to Repository', - 'icon': 'fa-upload' - } - ]; - - if (Features.BUILD_SUPPORT) { - var buildEvents = [ - { - 'id': 'build_queued', - 'title': 'Dockerfile Build Queued', - 'icon': 'fa-tasks' - }, - { - 'id': 'build_start', - 'title': 'Dockerfile Build Started', - 'icon': 'fa-circle-o-notch' - }, - { - 'id': 'build_success', - 'title': 'Dockerfile Build Successfully Completed', - 'icon': 'fa-check-circle-o' - }, - { - 'id': 'build_failure', - 'title': 'Dockerfile Build Failed', - 'icon': 'fa-times-circle-o' - }]; - - for (var i = 0; i < buildEvents.length; ++i) { - events.push(buildEvents[i]); - } - } - - var methods = [ - { - 'id': 'quay_notification', - 'title': Config.REGISTRY_TITLE + ' Notification', - 'icon': 'quay-icon', - 'fields': [ - { - 'name': 'target', - 'type': 'entity', - 'title': 'Recipient' - } - ] - }, - { - 'id': 'email', - 'title': 'E-mail', - 'icon': 'fa-envelope', - 'fields': [ - { - 'name': 'email', - 'type': 'email', - 'title': 'E-mail address' - } - ], - 'enabled': Features.MAILING - }, - { - 'id': 'webhook', - 'title': 'Webhook POST', - 'icon': 'fa-link', - 'fields': [ - { - 'name': 'url', - 'type': 'url', - 'title': 'Webhook URL' - } - ] - }, - { - 'id': 'flowdock', - 'title': 'Flowdock Team Notification', - 'icon': 'flowdock-icon', - 'fields': [ - { - 'name': 'flow_api_token', - 'type': 'string', - 'title': 'Flow API Token', - 'help_url': 'https://www.flowdock.com/account/tokens' - } - ] - }, - { - 'id': 'hipchat', - 'title': 'HipChat Room Notification', - 'icon': 'hipchat-icon', - 'fields': [ - { - 'name': 'room_id', - 'type': 'string', - 'title': 'Room ID #' - }, - { - 'name': 'notification_token', - 'type': 'string', - 'title': 'Room Notification Token', - 'help_url': 'https://hipchat.com/rooms/tokens/{room_id}' - } - ] - }, - { - 'id': 'slack', - 'title': 'Slack Room Notification', - 'icon': 'slack-icon', - 'fields': [ - { - 'name': 'url', - 'type': 'regex', - 'title': 'Webhook URL', - 'regex': '^https://hooks\\.slack\\.com/services/[A-Z0-9]+/[A-Z0-9]+/[a-zA-Z0-9]+$', - 'help_url': 'https://slack.com/services/new/incoming-webhook', - 'placeholder': 'https://hooks.slack.com/service/{some}/{token}/{here}' - } - ] - } - ]; - - var methodMap = {}; - var eventMap = {}; - - for (var i = 0; i < methods.length; ++i) { - methodMap[methods[i].id] = methods[i]; - } - - for (var i = 0; i < events.length; ++i) { - eventMap[events[i].id] = events[i]; - } - - externalNotificationData.getSupportedEvents = function() { - return events; - }; - - externalNotificationData.getSupportedMethods = function() { - var filtered = []; - for (var i = 0; i < methods.length; ++i) { - if (methods[i].enabled !== false) { - filtered.push(methods[i]); - } - } - return filtered; - }; - - externalNotificationData.getEventInfo = function(event) { - return eventMap[event]; - }; - - externalNotificationData.getMethodInfo = function(method) { - return methodMap[method]; - }; - - return externalNotificationData; +// Configure analytics. +if (window.__config && window.__config.MIXPANEL_KEY) { + quayApp.config(['$analyticsProvider', function($analyticsProvider) { + $analyticsProvider.virtualPageviews(true); }]); - - $provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', '$location', - function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config, $location) { - var notificationService = { - 'user': null, - 'notifications': [], - 'notificationClasses': [], - 'notificationSummaries': [], - 'additionalNotifications': false - }; - - var pollTimerHandle = null; - - var notificationKinds = { - 'test_notification': { - 'level': 'primary', - 'message': 'This notification is a long message for testing: {obj}', - 'page': '/about/', - 'dismissable': true - }, - 'org_team_invite': { - 'level': 'primary', - 'message': '{inviter} is inviting you to join team {team} under organization {org}', - 'actions': [ - { - 'title': 'Join team', - 'kind': 'primary', - 'handler': function(notification) { - window.location = '/confirminvite?code=' + notification.metadata['code']; - } - }, - { - 'title': 'Decline', - 'kind': 'default', - 'handler': function(notification) { - ApiService.declineOrganizationTeamInvite(null, {'code': notification.metadata['code']}).then(function() { - notificationService.update(); - }); - } - } - ] - }, - 'password_required': { - 'level': 'error', - 'message': 'In order to begin pushing and pulling repositories, a password must be set for your account', - 'page': '/user?tab=password' - }, - 'over_private_usage': { - 'level': 'error', - 'message': 'Namespace {namespace} is over its allowed private repository count. ' + - '

Please upgrade your plan to avoid disruptions in service.', - 'page': function(metadata) { - var organization = UserService.getOrganization(metadata['namespace']); - if (organization) { - return '/organization/' + metadata['namespace'] + '/admin'; - } else { - return '/user'; - } - } - }, - 'expiring_license': { - 'level': 'error', - 'message': 'Your license will expire at: {expires_at} ' + - '

Please contact support to purchase a new license.', - 'page': '/contact/' - }, - 'maintenance': { - 'level': 'warning', - 'message': 'We will be down for schedule maintenance from {from_date} to {to_date} ' + - 'for {reason}. We are sorry about any inconvenience.', - 'page': 'http://status.quay.io/' - }, - 'repo_push': { - 'level': 'info', - 'message': function(metadata) { - if (metadata.updated_tags && Object.getOwnPropertyNames(metadata.updated_tags).length) { - return 'Repository {repository} has been pushed with the following tags updated: {updated_tags}'; - } else { - return 'Repository {repository} fhas been pushed'; - } - }, - 'page': function(metadata) { - return '/repository/' + metadata.repository; - }, - 'dismissable': true - }, - 'build_queued': { - 'level': 'info', - 'message': 'A build has been queued for repository {repository}', - 'page': function(metadata) { - return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; - }, - 'dismissable': true - }, - 'build_start': { - 'level': 'info', - 'message': 'A build has been started for repository {repository}', - 'page': function(metadata) { - return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; - }, - 'dismissable': true - }, - 'build_success': { - 'level': 'info', - 'message': 'A build has succeeded for repository {repository}', - 'page': function(metadata) { - return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; - }, - 'dismissable': true - }, - 'build_failure': { - 'level': 'error', - 'message': 'A build has failed for repository {repository}', - 'page': function(metadata) { - return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; - }, - 'dismissable': true - } - }; - - notificationService.dismissNotification = function(notification) { - notification.dismissed = true; - var params = { - 'uuid': notification.id - }; - - ApiService.updateUserNotification(notification, params, function() { - notificationService.update(); - }, ApiService.errorDisplay('Could not update notification')); - - var index = $.inArray(notification, notificationService.notifications); - if (index >= 0) { - notificationService.notifications.splice(index, 1); - } - }; - - notificationService.getActions = function(notification) { - var kindInfo = notificationKinds[notification['kind']]; - if (!kindInfo) { - return []; - } - - return kindInfo['actions'] || []; - }; - - notificationService.canDismiss = function(notification) { - var kindInfo = notificationKinds[notification['kind']]; - if (!kindInfo) { - return false; - } - return !!kindInfo['dismissable']; - }; - - notificationService.getPage = function(notification) { - var kindInfo = notificationKinds[notification['kind']]; - if (!kindInfo) { - return null; - } - - var page = kindInfo['page']; - if (page != null && typeof page != 'string') { - page = page(notification['metadata']); - } - return page || ''; - }; - - notificationService.getMessage = function(notification) { - var kindInfo = notificationKinds[notification['kind']]; - if (!kindInfo) { - return '(Unknown notification kind: ' + notification['kind'] + ')'; - } - return StringBuilderService.buildString(kindInfo['message'], notification['metadata']); - }; - - notificationService.getClass = function(notification) { - var kindInfo = notificationKinds[notification['kind']]; - if (!kindInfo) { - return 'notification-info'; - } - return 'notification-' + kindInfo['level']; - }; - - notificationService.getClasses = function(notifications) { - var classes = []; - for (var i = 0; i < notifications.length; ++i) { - var notification = notifications[i]; - classes.push(notificationService.getClass(notification)); - } - return classes.join(' '); - }; - - notificationService.update = function() { - var user = UserService.currentUser(); - if (!user || user.anonymous) { - return; - } - - ApiService.listUserNotifications().then(function(resp) { - notificationService.notifications = resp['notifications']; - notificationService.additionalNotifications = resp['additional']; - notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications); - }); - }; - - notificationService.reset = function() { - $interval.cancel(pollTimerHandle); - pollTimerHandle = $interval(notificationService.update, 5 * 60 * 1000 /* five minutes */); - }; - - // Watch for plan changes and update. - PlanService.registerListener(this, function(plan) { - notificationService.reset(); - notificationService.update(); - }); - - // Watch for user changes and update. - $rootScope.$watch(function() { return UserService.currentUser(); }, function(currentUser) { - notificationService.reset(); - notificationService.update(); - }); - - return notificationService; - }]); - - $provide.factory('OAuthService', ['$location', 'Config', function($location, Config) { - var oauthService = {}; - oauthService.SCOPES = window.__auth_scopes; - return oauthService; - }]); - - $provide.factory('KeyService', ['$location', 'Config', function($location, Config) { - var keyService = {} - var oauth = window.__oauth; - - keyService['stripePublishableKey'] = Config['STRIPE_PUBLISHABLE_KEY']; - - keyService['githubTriggerClientId'] = oauth['GITHUB_TRIGGER_CONFIG']['CLIENT_ID']; - keyService['githubLoginClientId'] = oauth['GITHUB_LOGIN_CONFIG']['CLIENT_ID']; - keyService['googleLoginClientId'] = oauth['GOOGLE_LOGIN_CONFIG']['CLIENT_ID']; - - keyService['githubRedirectUri'] = Config.getUrl('/oauth2/github/callback'); - keyService['googleRedirectUri'] = Config.getUrl('/oauth2/google/callback'); - - keyService['githubLoginUrl'] = oauth['GITHUB_LOGIN_CONFIG']['AUTHORIZE_ENDPOINT']; - keyService['googleLoginUrl'] = oauth['GOOGLE_LOGIN_CONFIG']['AUTHORIZE_ENDPOINT']; - - keyService['githubEndpoint'] = oauth['GITHUB_LOGIN_CONFIG']['GITHUB_ENDPOINT']; - - keyService['githubTriggerEndpoint'] = oauth['GITHUB_TRIGGER_CONFIG']['GITHUB_ENDPOINT']; - keyService['githubTriggerAuthorizeUrl'] = oauth['GITHUB_TRIGGER_CONFIG']['AUTHORIZE_ENDPOINT']; - - keyService['githubLoginScope'] = 'user:email'; - keyService['googleLoginScope'] = 'openid email'; - - keyService.isEnterprise = function(service) { - switch (service) { - case 'github': - return keyService['githubLoginUrl'].indexOf('https://github.com/') < 0; - - case 'github-trigger': - return keyService['githubTriggerAuthorizeUrl'].indexOf('https://github.com/') < 0; - } - - return false; - }; - - keyService.getExternalLoginUrl = function(service, action) { - var state_clause = ''; - if (Config.MIXPANEL_KEY && window.mixpanel) { - if (mixpanel.get_distinct_id !== undefined) { - state_clause = "&state=" + encodeURIComponent(mixpanel.get_distinct_id()); - } - } - - var client_id = keyService[service + 'LoginClientId']; - var scope = keyService[service + 'LoginScope']; - var redirect_uri = keyService[service + 'RedirectUri']; - if (action == 'attach') { - redirect_uri += '/attach'; - } - - var url = keyService[service + 'LoginUrl'] + 'client_id=' + client_id + '&scope=' + scope + - '&redirect_uri=' + redirect_uri + state_clause; - - return url; - }; - - return keyService; - }]); - - $provide.factory('PlanService', ['KeyService', 'UserService', 'CookieService', 'ApiService', 'Features', 'Config', - function(KeyService, UserService, CookieService, ApiService, Features, Config) { - var plans = null; - var planDict = {}; - var planService = {}; - var listeners = []; - - var previousSubscribeFailure = false; - - planService.getFreePlan = function() { - return 'free'; - }; - - planService.registerListener = function(obj, callback) { - listeners.push({'obj': obj, 'callback': callback}); - }; - - planService.unregisterListener = function(obj) { - for (var i = 0; i < listeners.length; ++i) { - if (listeners[i].obj == obj) { - listeners.splice(i, 1); - break; - } - } - }; - - planService.notePlan = function(planId) { - if (Features.BILLING) { - CookieService.putSession('quay.notedplan', planId); - } - }; - - planService.isOrgCompatible = function(plan) { - return plan['stripeId'] == planService.getFreePlan() || plan['bus_features']; - }; - - planService.getMatchingBusinessPlan = function(callback) { - planService.getPlans(function() { - planService.getSubscription(null, function(sub) { - var plan = planDict[sub.plan]; - if (!plan) { - planService.getMinimumPlan(0, true, callback); - return; - } - - var count = Math.max(sub.usedPrivateRepos, plan.privateRepos); - planService.getMinimumPlan(count, true, callback); - }, function() { - planService.getMinimumPlan(0, true, callback); - }); - }); - }; - - planService.handleNotedPlan = function() { - var planId = planService.getAndResetNotedPlan(); - if (!planId || !Features.BILLING) { return false; } - - UserService.load(function() { - if (UserService.currentUser().anonymous) { - return; - } - - planService.getPlan(planId, function(plan) { - if (planService.isOrgCompatible(plan)) { - document.location = '/organizations/new/?plan=' + planId; - } else { - document.location = '/user?plan=' + planId; - } - }); - }); - - return true; - }; - - planService.getAndResetNotedPlan = function() { - var planId = CookieService.get('quay.notedplan'); - CookieService.clear('quay.notedplan'); - return planId; - }; - - planService.handleCardError = function(resp) { - if (!planService.isCardError(resp)) { return; } - - bootbox.dialog({ - "message": resp.data.carderror, - "title": "Credit card issue", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }; - - planService.isCardError = function(resp) { - return resp && resp.data && resp.data.carderror; - }; - - planService.verifyLoaded = function(callback) { - if (!Features.BILLING) { return; } - - if (plans && plans.length) { - callback(plans); - return; - } - - ApiService.listPlans().then(function(data) { - plans = data.plans || []; - for(var i = 0; i < plans.length; i++) { - planDict[plans[i].stripeId] = plans[i]; - } - callback(plans); - }, function() { callback([]); }); - }; - - planService.getPlans = function(callback, opt_includePersonal) { - planService.verifyLoaded(function() { - var filtered = []; - for (var i = 0; i < plans.length; ++i) { - var plan = plans[i]; - if (plan['deprecated']) { continue; } - if (!opt_includePersonal && !planService.isOrgCompatible(plan)) { continue; } - filtered.push(plan); - } - callback(filtered); - }); - }; - - planService.getPlan = function(planId, callback) { - planService.getPlanIncludingDeprecated(planId, function(plan) { - if (!plan['deprecated']) { - callback(plan); - } - }); - }; - - planService.getPlanIncludingDeprecated = function(planId, callback) { - planService.verifyLoaded(function() { - if (planDict[planId]) { - callback(planDict[planId]); - } - }); - }; - - planService.getMinimumPlan = function(privateCount, isBusiness, callback) { - planService.getPlans(function(plans) { - for (var i = 0; i < plans.length; i++) { - var plan = plans[i]; - if (plan.privateRepos >= privateCount) { - callback(plan); - return; - } - } - - callback(null); - }, /* include personal */!isBusiness); - }; - - planService.getSubscription = function(orgname, success, failure) { - if (!Features.BILLING) { return; } - - ApiService.getSubscription(orgname).then(success, failure); - }; - - planService.setSubscription = function(orgname, planId, success, failure, opt_token) { - if (!Features.BILLING) { return; } - - var subscriptionDetails = { - plan: planId - }; - - if (opt_token) { - subscriptionDetails['token'] = opt_token.id; - } - - ApiService.updateSubscription(orgname, subscriptionDetails).then(function(resp) { - success(resp); - planService.getPlan(planId, function(plan) { - for (var i = 0; i < listeners.length; ++i) { - listeners[i]['callback'](plan); - } - }); - }, failure); - }; - - planService.getCardInfo = function(orgname, callback) { - if (!Features.BILLING) { return; } - - ApiService.getCard(orgname).then(function(resp) { - callback(resp.card); - }, function() { - callback({'is_valid': false}); - }); - }; - - planService.changePlan = function($scope, orgname, planId, callbacks, opt_async) { - if (!Features.BILLING) { return; } - - if (callbacks['started']) { - callbacks['started'](); - } - - planService.getPlan(planId, function(plan) { - if (orgname && !planService.isOrgCompatible(plan)) { return; } - - planService.getCardInfo(orgname, function(cardInfo) { - if (plan.price > 0 && (previousSubscribeFailure || !cardInfo.last4)) { - var title = cardInfo.last4 ? 'Subscribe' : 'Start Trial ({{amount}} plan)'; - planService.showSubscribeDialog($scope, orgname, planId, callbacks, title, /* async */true); - return; - } - - previousSubscribeFailure = false; - - planService.setSubscription(orgname, planId, callbacks['success'], function(resp) { - previousSubscribeFailure = true; - planService.handleCardError(resp); - callbacks['failure'](resp); - }); - }); - }); - }; - - planService.changeCreditCard = function($scope, orgname, callbacks) { - if (!Features.BILLING) { return; } - - if (callbacks['opening']) { - callbacks['opening'](); - } - - var submitted = false; - var submitToken = function(token) { - if (submitted) { return; } - submitted = true; - $scope.$apply(function() { - if (callbacks['started']) { - callbacks['started'](); - } - - var cardInfo = { - 'token': token.id - }; - - ApiService.setCard(orgname, cardInfo).then(callbacks['success'], function(resp) { - planService.handleCardError(resp); - callbacks['failure'](resp); - }); - }); - }; - - var email = planService.getEmail(orgname); - StripeCheckout.open({ - key: KeyService.stripePublishableKey, - address: false, - email: email, - currency: 'usd', - name: 'Update credit card', - description: 'Enter your credit card number', - panelLabel: 'Update', - token: submitToken, - image: 'static/img/quay-icon-stripe.png', - opened: function() { $scope.$apply(function() { callbacks['opened']() }); }, - closed: function() { $scope.$apply(function() { callbacks['closed']() }); } - }); - }; - - planService.getEmail = function(orgname) { - var email = null; - if (UserService.currentUser()) { - email = UserService.currentUser().email; - - if (orgname) { - org = UserService.getOrganization(orgname); - if (org) { - emaiil = org.email; - } - } - } - return email; - }; - - planService.showSubscribeDialog = function($scope, orgname, planId, callbacks, opt_title, opt_async) { - if (!Features.BILLING) { return; } - - // If the async parameter is true and this is a browser that does not allow async popup of the - // Stripe dialog (such as Mobile Safari or IE), show a bootbox to show the dialog instead. - var isIE = navigator.appName.indexOf("Internet Explorer") != -1; - var isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/); - - if (opt_async && (isIE || isMobileSafari)) { - bootbox.dialog({ - "message": "Please click 'Subscribe' to continue", - "buttons": { - "subscribe": { - "label": "Subscribe", - "className": "btn-primary", - "callback": function() { - planService.showSubscribeDialog($scope, orgname, planId, callbacks, opt_title, false); - } - }, - "close": { - "label": "Cancel", - "className": "btn-default" - } - } - }); - return; - } - - if (callbacks['opening']) { - callbacks['opening'](); - } - - var submitted = false; - var submitToken = function(token) { - if (submitted) { return; } - submitted = true; - - if (Config.MIXPANEL_KEY) { - mixpanel.track('plan_subscribe'); - } - - $scope.$apply(function() { - if (callbacks['started']) { - callbacks['started'](); - } - planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure'], token); - }); - }; - - planService.getPlan(planId, function(planDetails) { - var email = planService.getEmail(orgname); - StripeCheckout.open({ - key: KeyService.stripePublishableKey, - address: false, - email: email, - amount: planDetails.price, - currency: 'usd', - name: 'Quay.io ' + planDetails.title + ' Subscription', - description: 'Up to ' + planDetails.privateRepos + ' private repositories', - panelLabel: opt_title || 'Subscribe', - token: submitToken, - image: 'static/img/quay-icon-stripe.png', - opened: function() { $scope.$apply(function() { callbacks['opened']() }); }, - closed: function() { $scope.$apply(function() { callbacks['closed']() }); } - }); - }); - }; - - return planService; - }]); - }). - directive('match', function($parse) { - return { - require: 'ngModel', - link: function(scope, elem, attrs, ctrl) { - scope.$watch(function() { - return $parse(attrs.match)(scope) === ctrl.$modelValue; - }, function(currentValue) { - ctrl.$setValidity('mismatch', currentValue); - }); - } - }; - }). - directive('onresize', function ($window, $parse) { - return function (scope, element, attr) { - var fn = $parse(attr.onresize); - - var notifyResized = function() { - scope.$apply(function () { - fn(scope); - }); - }; - - angular.element($window).on('resize', null, notifyResized); - - scope.$on('$destroy', function() { - angular.element($window).off('resize', null, notifyResized); - }); - }; - }). - config(['$routeProvider', '$locationProvider', - function($routeProvider, $locationProvider) { - var title = window.__config['REGISTRY_TITLE'] || 'Quay.io'; - - $locationProvider.html5Mode(true); - - // WARNING WARNING WARNING - // If you add a route here, you must add a corresponding route in thr endpoints/web.py - // index rule to make sure that deep links directly deep into the app continue to work. - // WARNING WARNING WARNING - $routeProvider. - when('/repository/:namespace/:name', {templateUrl: '/static/partials/view-repo.html', controller: RepoCtrl, - fixFooter: false, reloadOnSearch: false}). - when('/repository/:namespace/:name/tag/:tag', {templateUrl: '/static/partials/view-repo.html', controller: RepoCtrl, - fixFooter: false}). - when('/repository/:namespace/:name/image/:image', {templateUrl: '/static/partials/image-view.html', controller: ImageViewCtrl, reloadOnSearch: false}). - when('/repository/:namespace/:name/admin', {templateUrl: '/static/partials/repo-admin.html', controller:RepoAdminCtrl, reloadOnSearch: false}). - when('/repository/:namespace/:name/build', {templateUrl: '/static/partials/repo-build.html', controller:RepoBuildCtrl, reloadOnSearch: false}). - when('/repository/:namespace/:name/build/:buildid/buildpack', {templateUrl: '/static/partials/build-package.html', controller:BuildPackageCtrl, reloadOnSearch: false}). - when('/repository/', {title: 'Repositories', description: 'Public and private docker repositories list', - templateUrl: '/static/partials/repo-list.html', controller: RepoListCtrl, reloadOnSearch: false}). - when('/user/', {title: 'Account Settings', description:'Account settings for ' + title, templateUrl: '/static/partials/user-admin.html', - reloadOnSearch: false, controller: UserAdminCtrl}). - when('/superuser/', {title: 'Enterprise Registry Management', description:'Admin panel for ' + title, templateUrl: '/static/partials/super-user.html', - reloadOnSearch: false, controller: SuperUserAdminCtrl, newLayout: true}). - when('/setup/', {title: 'Enterprise Registry Setup', description:'Setup for ' + title, templateUrl: '/static/partials/setup.html', - reloadOnSearch: false, controller: SetupCtrl, newLayout: true}). - when('/guide/', {title: 'Guide', description:'Guide to using private docker repositories on ' + title, - templateUrl: '/static/partials/guide.html', - controller: GuideCtrl}). - when('/tutorial/', {title: 'Tutorial', description:'Interactive tutorial for using ' + title, templateUrl: '/static/partials/tutorial.html', - controller: TutorialCtrl}). - when('/contact/', {title: 'Contact Us', description:'Different ways for you to get a hold of us when you need us most.', templateUrl: '/static/partials/contact.html', - controller: ContactCtrl}). - when('/about/', {title: 'About Us', description:'Information about the Quay.io team and the company.', templateUrl: '/static/partials/about.html'}). - when('/plans/', {title: 'Plans and Pricing', description: 'Plans and pricing for private docker repositories on Quay.io', - templateUrl: '/static/partials/plans.html', controller: PlansCtrl}). - when('/security/', {title: 'Security', description: 'Security features used when transmitting and storing data', - templateUrl: '/static/partials/security.html'}). - when('/signin/', {title: 'Sign In', description: 'Sign into ' + title, templateUrl: '/static/partials/signin.html', controller: SignInCtrl, reloadOnSearch: false}). - when('/new/', {title: 'Create new repository', description: 'Create a new public or private docker repository, optionally constructing from a dockerfile', - templateUrl: '/static/partials/new-repo.html', controller: NewRepoCtrl}). - when('/organizations/', {title: 'Organizations', description: 'Private docker repository hosting for businesses and organizations', - templateUrl: '/static/partials/organizations.html', controller: OrgsCtrl}). - when('/organizations/new/', {title: 'New Organization', description: 'Create a new organization on ' + title, - templateUrl: '/static/partials/new-organization.html', controller: NewOrgCtrl}). - when('/organization/:orgname', {templateUrl: '/static/partials/org-view.html', controller: OrgViewCtrl}). - when('/organization/:orgname/admin', {templateUrl: '/static/partials/org-admin.html', controller: OrgAdminCtrl, reloadOnSearch: false}). - when('/organization/:orgname/teams/:teamname', {templateUrl: '/static/partials/team-view.html', controller: TeamViewCtrl}). - when('/organization/:orgname/logs/:membername', {templateUrl: '/static/partials/org-member-logs.html', controller: OrgMemberLogsCtrl}). - when('/organization/:orgname/application/:clientid', {templateUrl: '/static/partials/manage-application.html', - controller: ManageApplicationCtrl, reloadOnSearch: false}). - when('/v1/', {title: 'Activation information', templateUrl: '/static/partials/v1-page.html', controller: V1Ctrl}). - - - when('/tour/', {title: title + ' Tour', templateUrl: '/static/partials/tour.html', controller: TourCtrl}). - when('/tour/organizations', {title: 'Teams and Organizations Tour', templateUrl: '/static/partials/tour.html', controller: TourCtrl}). - when('/tour/features', {title: title + ' Features', templateUrl: '/static/partials/tour.html', controller: TourCtrl}). - when('/tour/enterprise', {title: 'Enterprise Edition', templateUrl: '/static/partials/tour.html', controller: TourCtrl}). - - when('/confirminvite', {title: 'Confirm Invite', templateUrl: '/static/partials/confirm-invite.html', controller: ConfirmInviteCtrl, reloadOnSearch: false}). - - when('/', {title: 'Hosted Private Docker Registry', templateUrl: '/static/partials/landing.html', controller: LandingCtrl, - pageClass: 'landing-page'}). - when ('/starred/', {title: 'Starred Repositories', templateUrl: '/static/partials/starred.html', controller: StarCtrl}). - otherwise({redirectTo: '/'}); - }]). - config(function(RestangularProvider) { - RestangularProvider.setBaseUrl('/api/v1/'); - }); - - if (window.__config && window.__config.MIXPANEL_KEY) { - quayApp.config(['$analyticsProvider', function($analyticsProvider) { - $analyticsProvider.virtualPageviews(true); - }]); - } - - if (window.__config && window.__config.SENTRY_PUBLIC_DSN) { - quayApp.config(function($provide) { - $provide.decorator("$exceptionHandler", function($delegate) { - return function(ex, cause) { - $delegate(ex, cause); - Raven.captureException(ex, {extra: {cause: cause}}); - }; - }); - }); - } - - -function buildConditionalLinker($animate, name, evaluator) { - // Based off of a solution found here: http://stackoverflow.com/questions/20325480/angularjs-whats-the-best-practice-to-add-ngif-to-a-directive-programmatically - return function ($scope, $element, $attr, ctrl, $transclude) { - var block; - var childScope; - var roles; - - $attr.$observe(name, function (value) { - if (evaluator($scope.$eval(value))) { - if (!childScope) { - childScope = $scope.$new(); - $transclude(childScope, function (clone) { - block = { - startNode: clone[0], - endNode: clone[clone.length++] = document.createComment(' end ' + name + ': ' + $attr[name] + ' ') - }; - $animate.enter(clone, $element.parent(), $element); - }); - } - } else { - if (childScope) { - childScope.$destroy(); - childScope = null; - } - - if (block) { - $animate.leave(getBlockElements(block)); - block = null; - } - } - }); - } } -quayApp.directive('quayRequire', function ($animate, Features) { - return { - transclude: 'element', - priority: 600, - terminal: true, - restrict: 'A', - link: buildConditionalLinker($animate, 'quayRequire', function(value) { - return Features.matchesFeatures(value); - }) - }; -}); - - -quayApp.directive('quayShow', function($animate, Features, Config) { - return { - priority: 590, - restrict: 'A', - link: function($scope, $element, $attr, ctrl, $transclude) { - $scope.Features = Features; - $scope.Config = Config; - $scope.$watch($attr.quayShow, function(result) { - $animate[!!result ? 'removeClass' : 'addClass']($element, 'ng-hide'); - }); - } - }; -}); - - -quayApp.directive('ngIfMedia', function ($animate) { - return { - transclude: 'element', - priority: 600, - terminal: true, - restrict: 'A', - link: buildConditionalLinker($animate, 'ngIfMedia', function(value) { - return window.matchMedia(value).matches; - }) - }; -}); - - -quayApp.directive('quaySection', function($animate, $location, $rootScope) { - return { - priority: 590, - restrict: 'A', - link: function($scope, $element, $attr, ctrl, $transclude) { - var update = function() { - var result = $location.path().indexOf('/' + $attr.quaySection) == 0; - $animate[!result ? 'removeClass' : 'addClass']($element, 'active'); +// Configure sentry. +if (window.__config && window.__config.SENTRY_PUBLIC_DSN) { + quayApp.config(function($provide) { + $provide.decorator("$exceptionHandler", function($delegate) { + return function(ex, cause) { + $delegate(ex, cause); + Raven.captureException(ex, {extra: {cause: cause}}); }; - - $scope.$watch(function(){ - return $location.path(); - }, update); - - $scope.$watch($attr.quaySection, update); - } - }; -}); - - -quayApp.directive('quayClasses', function(Features, Config) { - return { - priority: 580, - restrict: 'A', - link: function($scope, $element, $attr, ctrl, $transclude) { - - // Borrowed from ngClass. - function flattenClasses(classVal) { - if(angular.isArray(classVal)) { - return classVal.join(' '); - } else if (angular.isObject(classVal)) { - var classes = [], i = 0; - angular.forEach(classVal, function(v, k) { - if (v) { - classes.push(k); - } - }); - return classes.join(' '); - } - - return classVal; - } - - function removeClass(classVal) { - $attr.$removeClass(flattenClasses(classVal)); - } - - - function addClass(classVal) { - $attr.$addClass(flattenClasses(classVal)); - } - - $scope.$watch($attr.quayClasses, function(result) { - var scopeVals = { - 'Features': Features, - 'Config': Config - }; - - for (var expr in result) { - if (!result.hasOwnProperty(expr)) { continue; } - - // Evaluate the expression with the entire features list added. - var value = $scope.$eval(expr, scopeVals); - if (value) { - addClass(result[expr]); - } else { - removeClass(result[expr]); - } - } - }); - } - }; -}); - - -quayApp.directive('quayInclude', function($compile, $templateCache, $http, Features, Config) { - return { - priority: 595, - restrict: 'A', - link: function($scope, $element, $attr, ctrl) { - var getTemplate = function(templateName) { - var templateUrl = '/static/partials/' + templateName; - return $http.get(templateUrl, {cache: $templateCache}); - }; - - var result = $scope.$eval($attr.quayInclude); - if (!result) { - return; - } - - var scopeVals = { - 'Features': Features, - 'Config': Config - }; - - var templatePath = null; - for (var expr in result) { - if (!result.hasOwnProperty(expr)) { continue; } - - // Evaluate the expression with the entire features list added. - var value = $scope.$eval(expr, scopeVals); - if (value) { - templatePath = result[expr]; - break; - } - } - - if (!templatePath) { - return; - } - - var promise = getTemplate(templatePath).success(function(html) { - $element.html(html); - }).then(function (response) { - $element.replaceWith($compile($element.html())($scope)); - if ($attr.onload) { - $scope.$eval($attr.onload); - } - }); - } - }; -}); - - -quayApp.directive('entityReference', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/entity-reference.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'entity': '=entity', - 'namespace': '=namespace', - 'showAvatar': '@showAvatar', - 'avatarSize': '@avatarSize' - }, - controller: function($scope, $element, UserService, UtilService) { - $scope.getIsAdmin = function(namespace) { - return UserService.isNamespaceAdmin(namespace); - }; - - $scope.getRobotUrl = function(name) { - var namespace = $scope.getPrefix(name); - if (!namespace) { - return ''; - } - - if (!$scope.getIsAdmin(namespace)) { - return ''; - } - - var org = UserService.getOrganization(namespace); - if (!org) { - // This robot is owned by the user. - return '/user/?tab=robots&showRobot=' + UtilService.textToSafeHtml(name); - } - - return '/organization/' + org['name'] + '/admin?tab=robots&showRobot=' + UtilService.textToSafeHtml(name); - }; - - $scope.getPrefix = function(name) { - if (!name) { return ''; } - var plus = name.indexOf('+'); - return name.substr(0, plus); - }; - - $scope.getShortenedName = function(name) { - if (!name) { return ''; } - var plus = name.indexOf('+'); - return name.substr(plus + 1); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('applicationInfo', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/application-info.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'application': '=application' - }, - controller: function($scope, $element, ApiService) { - - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('applicationReference', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/application-reference.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'title': '=title', - 'clientId': '=clientId' - }, - controller: function($scope, $element, ApiService, $modal) { - $scope.showAppDetails = function() { - var params = { - 'client_id': $scope.clientId - }; - - ApiService.getApplicationInformation(null, params).then(function(resp) { - $scope.applicationInfo = resp; - $modal({ - title: 'Application Information', - scope: $scope, - template: '/static/directives/application-reference-dialog.html', - show: true - }); - }, ApiService.errorDisplay('Application could not be found')); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('markdownView', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/markdown-view.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'content': '=content', - 'firstLineOnly': '=firstLineOnly' - }, - controller: function($scope, $element, $sce) { - $scope.getMarkedDown = function(content, firstLineOnly) { - if (firstLineOnly) { - content = getFirstTextLine(content); - } - return $sce.trustAsHtml(getMarkedDown(content)); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('repoBreadcrumb', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/repo-breadcrumb.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'repo': '=repo', - 'image': '=image', - 'subsection': '=subsection', - 'subsectionIcon': '=subsectionIcon' - }, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; -}); - -quayApp.directive('focusablePopoverContent', ['$timeout', '$popover', function ($timeout, $popover) { - return { - restrict: "A", - link: function (scope, element, attrs) { - $body = $('body'); - var hide = function() { - $body.off('click'); - - if (!scope) { return; } - scope.$apply(function() { - if (!scope || !$scope.$hide) { return; } - scope.$hide(); - }); - }; - - scope.$on('$destroy', function() { - $body.off('click'); - }); - - $timeout(function() { - $body.on('click', function(evt) { - var target = evt.target; - var isPanelMember = $(element).has(target).length > 0 || target == element; - if (!isPanelMember) { - hide(); - } - }); - - $(element).find('input').focus(); - }, 100); - } - }; -}]); - -quayApp.directive('repoCircle', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/repo-circle.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'repo': '=repo' - }, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('copyBox', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/copy-box.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'value': '=value', - 'hoveringMessage': '=hoveringMessage', - }, - controller: function($scope, $element, $rootScope) { - $scope.disabled = false; - - var number = $rootScope.__copyBoxIdCounter || 0; - $rootScope.__copyBoxIdCounter = number + 1; - $scope.inputId = "copy-box-input-" + number; - - var button = $($element).find('.copy-icon'); - var input = $($element).find('input'); - - input.attr('id', $scope.inputId); - button.attr('data-clipboard-target', $scope.inputId); - $scope.disabled = !button.clipboardCopy(); - } - }; - return directiveDefinitionObject; -}); - - - -quayApp.directive('userSetup', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/user-setup.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'redirectUrl': '=redirectUrl', - - 'inviteCode': '=inviteCode', - - 'signInStarted': '&signInStarted', - 'signedIn': '&signedIn', - 'userRegistered': '&userRegistered' - }, - controller: function($scope, $location, $timeout, ApiService, KeyService, UserService) { - $scope.sendRecovery = function() { - $scope.sendingRecovery = true; - - ApiService.requestRecoveryEmail($scope.recovery).then(function() { - $scope.invalidRecovery = false; - $scope.errorMessage = ''; - $scope.sent = true; - $scope.sendingRecovery = false; - }, function(resp) { - $scope.invalidRecovery = true; - $scope.errorMessage = ApiService.getErrorMessage(resp, 'Cannot send recovery email'); - $scope.sent = false; - $scope.sendingRecovery = false; - }); - }; - - $scope.handleUserRegistered = function(username) { - $scope.userRegistered({'username': username}); - }; - - $scope.hasSignedIn = function() { - return UserService.hasEverLoggedIn(); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('externalLoginButton', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/external-login-button.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'signInStarted': '&signInStarted', - 'redirectUrl': '=redirectUrl', - 'provider': '@provider', - 'action': '@action' - }, - controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, Features, Config) { - $scope.signingIn = false; - $scope.isEnterprise = KeyService.isEnterprise; - - $scope.startSignin = function(service) { - $scope.signInStarted({'service': service}); - - var url = KeyService.getExternalLoginUrl(service, $scope.action || 'login'); - - // Save the redirect URL in a cookie so that we can redirect back after the service returns to us. - var redirectURL = $scope.redirectUrl || window.location.toString(); - CookieService.putPermanent('quay.redirectAfterLoad', redirectURL); - - // Needed to ensure that UI work done by the started callback is finished before the location - // changes. - $scope.signingIn = true; - $timeout(function() { - document.location = url; - }, 250); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('signinForm', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/signin-form.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'redirectUrl': '=redirectUrl', - 'signInStarted': '&signInStarted', - 'signedIn': '&signedIn' - }, - controller: function($scope, $location, $timeout, $interval, ApiService, KeyService, UserService, CookieService, Features, Config) { - $scope.tryAgainSoon = 0; - $scope.tryAgainInterval = null; - $scope.signingIn = false; - - $scope.markStarted = function() { - $scope.signingIn = true; - if ($scope.signInStarted != null) { - $scope.signInStarted(); - } - }; - - $scope.cancelInterval = function() { - $scope.tryAgainSoon = 0; - - if ($scope.tryAgainInterval) { - $interval.cancel($scope.tryAgainInterval); - } - - $scope.tryAgainInterval = null; - }; - - $scope.$watch('user.username', function() { - $scope.cancelInterval(); - }); - - $scope.$on('$destroy', function() { - $scope.cancelInterval(); - }); - - $scope.signin = function() { - if ($scope.tryAgainSoon > 0) { return; } - - $scope.markStarted(); - $scope.cancelInterval(); - - ApiService.signinUser($scope.user).then(function() { - $scope.signingIn = false; - $scope.needsEmailVerification = false; - $scope.invalidCredentials = false; - - if ($scope.signedIn != null) { - $scope.signedIn(); - } - - // Load the newly created user. - UserService.load(); - - // Redirect to the specified page or the landing page - // Note: The timeout of 500ms is needed to ensure dialogs containing sign in - // forms get removed before the location changes. - $timeout(function() { - var redirectUrl = $scope.redirectUrl; - if (redirectUrl == $location.path() || redirectUrl == null) { - return; - } - window.location = (redirectUrl ? redirectUrl : '/'); - }, 500); - }, function(result) { - $scope.signingIn = false; - - if (result.status == 429 /* try again later */) { - $scope.needsEmailVerification = false; - $scope.invalidCredentials = false; - - $scope.cancelInterval(); - - $scope.tryAgainSoon = result.headers('Retry-After'); - $scope.tryAgainInterval = $interval(function() { - $scope.tryAgainSoon--; - if ($scope.tryAgainSoon <= 0) { - $scope.cancelInterval(); - } - }, 1000, $scope.tryAgainSoon); - } else { - $scope.needsEmailVerification = result.data.needsEmailVerification; - $scope.invalidCredentials = result.data.invalidCredentials; - } - }); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('signupForm', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/signup-form.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'inviteCode': '=inviteCode', - - 'userRegistered': '&userRegistered' - }, - controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, Config, UIService) { - $('.form-signup').popover(); - - $scope.awaitingConfirmation = false; - $scope.registering = false; - - $scope.register = function() { - UIService.hidePopover('#signupButton'); - $scope.registering = true; - - if ($scope.inviteCode) { - $scope.newUser['invite_code'] = $scope.inviteCode; - } - - ApiService.createNewUser($scope.newUser).then(function(resp) { - $scope.registering = false; - $scope.awaitingConfirmation = !!resp['awaiting_verification']; - - if (Config.MIXPANEL_KEY) { - mixpanel.alias($scope.newUser.username); - } - - $scope.userRegistered({'username': $scope.newUser.username}); - - if (!$scope.awaitingConfirmation) { - document.location = '/'; - } - }, function(result) { - $scope.registering = false; - UIService.showFormError('#signupButton', result); - }); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('tourContent', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/tour-content.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'kind': '=kind' - }, - controller: function($scope, $element, $timeout, UserService) { - // Monitor any user changes and place the current user into the scope. - UserService.updateUserIn($scope); - - $scope.chromify = function() { - browserchrome.update(); - }; - - $scope.$watch('kind', function(kind) { - $timeout(function() { - $scope.chromify(); - }); - }); - }, - link: function($scope, $element, $attr, ctrl) { - $scope.chromify(); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('plansTable', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/plans-table.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'plans': '=plans', - 'currentPlan': '=currentPlan' - }, - controller: function($scope, $element) { - $scope.setPlan = function(plan) { - $scope.currentPlan = plan; - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('dockerAuthDialog', function (Config) { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/docker-auth-dialog.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'username': '=username', - 'token': '=token', - 'shown': '=shown', - 'counter': '=counter', - 'supportsRegenerate': '@supportsRegenerate', - 'regenerate': '®enerate' - }, - controller: function($scope, $element) { - var updateCommand = function() { - var escape = function(v) { - if (!v) { return v; } - return v.replace('$', '\\$'); - }; - $scope.command = 'docker login -e="." -u="' + escape($scope.username) + - '" -p="' + $scope.token + '" ' + Config['SERVER_HOSTNAME']; - }; - - $scope.$watch('username', updateCommand); - $scope.$watch('token', updateCommand); - - $scope.regenerating = true; - - $scope.askRegenerate = function() { - bootbox.confirm('Are you sure you want to regenerate the token? All existing login credentials will become invalid', function(resp) { - if (resp) { - $scope.regenerating = true; - $scope.regenerate({'username': $scope.username, 'token': $scope.token}); - } - }); - }; - - $scope.isDownloadSupported = function() { - var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent); - if (isSafari) { - // Doesn't work properly in Safari, sadly. - return false; - } - - try { return !!new Blob(); } catch(e) {} - return false; - }; - - $scope.downloadCfg = function() { - var auth = $.base64.encode($scope.username + ":" + $scope.token); - config = {} - config[Config['SERVER_HOSTNAME']] = { - "auth": auth, - "email": "" - }; - - var file = JSON.stringify(config, null, ' '); - var blob = new Blob([file]); - saveAs(blob, '.dockercfg'); - }; - - var show = function(r) { - $scope.regenerating = false; - - if (!$scope.shown || !$scope.username || !$scope.token) { - $('#dockerauthmodal').modal('hide'); - return; - } - - $('#copyClipboard').clipboardCopy(); - $('#dockerauthmodal').modal({}); - }; - - $scope.$watch('counter', show); - $scope.$watch('shown', show); - $scope.$watch('username', show); - $scope.$watch('token', show); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.filter('regex', function() { - return function(input, regex) { - if (!regex) { return []; } - - try { - var patt = new RegExp(regex); - } catch (ex) { - return []; - } - - var out = []; - for (var i = 0; i < input.length; ++i){ - var m = input[i].match(patt); - if (m && m[0].length == input[i].length) { - out.push(input[i]); - } - } - return out; - }; -}); - - -quayApp.filter('reverse', function() { - return function(items) { - return items.slice().reverse(); - }; -}); - - -quayApp.filter('bytes', function() { - return function(bytes, precision) { - if (!bytes || isNaN(parseFloat(bytes)) || !isFinite(bytes)) return 'Unknown'; - if (typeof precision === 'undefined') precision = 1; - var units = ['bytes', 'kB', 'MB', 'GB', 'TB', 'PB'], - number = Math.floor(Math.log(bytes) / Math.log(1024)); - return (bytes / Math.pow(1024, Math.floor(number))).toFixed(precision) + ' ' + units[number]; - } -}); - - -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; - }; -}); +// Run the application. +quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanService', '$http', '$timeout', 'CookieService', 'Features', '$anchorScroll', 'UtilService', + function($location, $rootScope, Restangular, UserService, PlanService, $http, $timeout, CookieService, Features, $anchorScroll, UtilService) { - -quayApp.directive('billingInvoices', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/billing-invoices.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'organization': '=organization', - 'user': '=user', - 'makevisible': '=makevisible' - }, - controller: function($scope, $element, $sce, ApiService) { - $scope.loading = false; - $scope.invoiceExpanded = {}; - - $scope.toggleInvoice = function(id) { - $scope.invoiceExpanded[id] = !$scope.invoiceExpanded[id]; - }; - - var update = function() { - var hasValidUser = !!$scope.user; - var hasValidOrg = !!$scope.organization; - var isValid = hasValidUser || hasValidOrg; - - if (!$scope.makevisible || !isValid) { - return; - } - - $scope.loading = true; - - ApiService.listInvoices($scope.organization).then(function(resp) { - $scope.invoices = resp.invoices; - $scope.loading = false; - }); - }; - - $scope.$watch('organization', update); - $scope.$watch('user', update); - $scope.$watch('makevisible', update); - } - }; - - return directiveDefinitionObject; -}); - - -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', - 'makevisible': '=makevisible', - 'repository': '=repository', - 'performer': '=performer', - 'allLogs': '@allLogs' - }, - controller: function($scope, $element, $sce, Restangular, ApiService, TriggerService, - StringBuilderService, ExternalNotificationData) { - $scope.loading = true; - $scope.logs = null; - $scope.kindsAllowed = null; - $scope.chartVisible = true; - $scope.logsPath = ''; - - var datetime = new Date(); - $scope.logStartDate = new Date(datetime.getUTCFullYear(), datetime.getUTCMonth(), datetime.getUTCDate() - 7); - $scope.logEndDate = new Date(datetime.getUTCFullYear(), datetime.getUTCMonth(), datetime.getUTCDate()); - - var defaultPermSuffix = function(metadata) { - if (metadata.activating_username) { - return ', when creating user is {activating_username}'; - } - return ''; - }; - - var logDescriptions = { - '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: {robot}', - 'delete_robot': 'Delete Robot Account: {robot}', - 'create_repo': 'Create Repository: {repo}', - 'push_repo': 'Push to repository: {repo}', - 'repo_verb': function(metadata) { - var prefix = ''; - if (metadata.verb == 'squash') { - prefix = 'Pull of squashed tag {tag}' - } - - if (metadata.token) { - if (metadata.token_type == 'build-worker') { - prefix += ' by build worker'; - } else { - prefix += ' via token'; - } - } else if (metadata.username) { - prefix += ' by {username}'; - } else { - prefix += ' by {_ip}'; - } - - return prefix; - }, - 'pull_repo': function(metadata) { - if (metadata.token) { - var prefix = 'Pull of repository' - if (metadata.token_type == 'build-worker') { - prefix += ' by build worker'; - } else { - prefix += ' via token'; - } - return prefix; - } else if (metadata.username) { - return 'Pull repository {repo} by {username}'; - } else { - return 'Public pull of repository {repo} by {_ip}'; - } - }, - 'delete_repo': 'Delete repository: {repo}', - 'change_repo_permission': function(metadata) { - if (metadata.username) { - return 'Change permission for user {username} in repository {repo} to {role}'; - } else if (metadata.team) { - return 'Change permission for team {team} in repository {repo} to {role}'; - } else if (metadata.token) { - return 'Change permission for token {token} in repository {repo} to {role}'; - } - }, - 'delete_repo_permission': function(metadata) { - if (metadata.username) { - return 'Remove permission for user {username} from repository {repo}'; - } else if (metadata.team) { - return 'Remove permission for team {team} from repository {repo}'; - } else if (metadata.token) { - return 'Remove permission for token {token} from repository {repo}'; - } - }, - 'delete_tag': 'Tag {tag} deleted in repository {repo} by user {username}', - 'create_tag': 'Tag {tag} created in repository {repo} on image {image} by user {username}', - 'move_tag': 'Tag {tag} moved from image {original_image} to image {image} in repository {repo} by user {username}', - 'change_repo_visibility': 'Change visibility for repository {repo} to {visibility}', - 'add_repo_accesstoken': 'Create access token {token} in repository {repo}', - 'delete_repo_accesstoken': 'Delete access token {token} in repository {repo}', - 'set_repo_description': 'Change description for repository {repo}: {description}', - 'build_dockerfile': function(metadata) { - if (metadata.trigger_id) { - var triggerDescription = TriggerService.getDescription( - metadata['service'], metadata['config']); - return 'Build image from Dockerfile for repository {repo} triggered by ' + triggerDescription; - } - return 'Build image from Dockerfile for repository {repo}'; - }, - 'org_create_team': 'Create team: {team}', - 'org_delete_team': 'Delete team: {team}', - 'org_add_team_member': 'Add member {member} to team {team}', - 'org_remove_team_member': 'Remove member {member} from team {team}', - 'org_invite_team_member': function(metadata) { - if (metadata.user) { - return 'Invite {user} to team {team}'; - } else { - return 'Invite {email} to team {team}'; - } - }, - 'org_delete_team_member_invite': function(metadata) { - if (metadata.user) { - return 'Rescind invite of {user} to team {team}'; - } else { - return 'Rescind invite of {email} to team {team}'; - } - }, - - 'org_team_member_invite_accepted': 'User {member}, invited by {inviter}, joined team {team}', - 'org_team_member_invite_declined': 'User {member}, invited by {inviter}, declined to join team {team}', - - 'org_set_team_description': 'Change description of team {team}: {description}', - 'org_set_team_role': 'Change permission of team {team} to {role}', - 'create_prototype_permission': function(metadata) { - if (metadata.delegate_user) { - return 'Create default permission: {role} for {delegate_user}' + defaultPermSuffix(metadata); - } else if (metadata.delegate_team) { - return 'Create default permission: {role} for {delegate_team}' + defaultPermSuffix(metadata); - } - }, - 'modify_prototype_permission': function(metadata) { - if (metadata.delegate_user) { - return 'Modify default permission: {role} (from {original_role}) for {delegate_user}' + defaultPermSuffix(metadata); - } else if (metadata.delegate_team) { - return 'Modify default permission: {role} (from {original_role}) for {delegate_team}' + defaultPermSuffix(metadata); - } - }, - 'delete_prototype_permission': function(metadata) { - if (metadata.delegate_user) { - return 'Delete default permission: {role} for {delegate_user}' + defaultPermSuffix(metadata); - } else if (metadata.delegate_team) { - return 'Delete default permission: {role} for {delegate_team}' + defaultPermSuffix(metadata); - } - }, - 'setup_repo_trigger': function(metadata) { - var triggerDescription = TriggerService.getDescription( - metadata['service'], metadata['config']); - return 'Setup build trigger - ' + triggerDescription; - }, - 'delete_repo_trigger': function(metadata) { - var triggerDescription = TriggerService.getDescription( - metadata['service'], metadata['config']); - return 'Delete build trigger - ' + triggerDescription; - }, - 'create_application': 'Create application {application_name} with client ID {client_id}', - 'update_application': 'Update application to {application_name} for client ID {client_id}', - 'delete_application': 'Delete application {application_name} with client ID {client_id}', - 'reset_application_client_secret': 'Reset the Client Secret of application {application_name} ' + - 'with client ID {client_id}', - - 'add_repo_notification': function(metadata) { - var eventData = ExternalNotificationData.getEventInfo(metadata.event); - return 'Add notification of event "' + eventData['title'] + '" for repository {repo}'; - }, - - 'delete_repo_notification': function(metadata) { - var eventData = ExternalNotificationData.getEventInfo(metadata.event); - return 'Delete notification of event "' + eventData['title'] + '" for repository {repo}'; - }, - - 'regenerate_robot_token': 'Regenerated token for robot {robot}', - - // Note: These are deprecated. - 'add_repo_webhook': 'Add webhook in repository {repo}', - 'delete_repo_webhook': 'Delete webhook in repository {repo}' - }; - - 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', - 'repo_verb': 'Pull Repo Verb', - 'pull_repo': 'Pull repository', - 'delete_repo': 'Delete 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', - 'set_repo_description': 'Change repository description', - 'build_dockerfile': 'Build image from Dockerfile', - 'delete_tag': 'Delete Tag', - 'create_tag': 'Create Tag', - 'move_tag': 'Move Tag', - 'org_create_team': 'Create team', - 'org_delete_team': 'Delete team', - 'org_add_team_member': 'Add team member', - 'org_invite_team_member': 'Invite team member', - 'org_delete_team_member_invite': 'Rescind team member invitation', - 'org_remove_team_member': 'Remove team member', - 'org_team_member_invite_accepted': 'Team invite accepted', - 'org_team_member_invite_declined': 'Team invite declined', - 'org_set_team_description': 'Change team description', - 'org_set_team_role': 'Change team permission', - 'create_prototype_permission': 'Create default permission', - 'modify_prototype_permission': 'Modify default permission', - 'delete_prototype_permission': 'Delete default permission', - 'setup_repo_trigger': 'Setup build trigger', - 'delete_repo_trigger': 'Delete build trigger', - 'create_application': 'Create Application', - 'update_application': 'Update Application', - 'delete_application': 'Delete Application', - 'reset_application_client_secret': 'Reset Client Secret', - 'add_repo_notification': 'Add repository notification', - 'delete_repo_notification': 'Delete repository notification', - 'regenerate_robot_token': 'Regenerate Robot Token', - - // Note: these are deprecated. - 'add_repo_webhook': 'Add webhook', - 'delete_repo_webhook': 'Delete webhook' - }; - - var getDateString = function(date) { - return (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear(); - }; - - var getOffsetDate = function(date, days) { - return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); - }; - - var update = function() { - var hasValidUser = !!$scope.user; - var hasValidOrg = !!$scope.organization; - var hasValidRepo = $scope.repository && $scope.repository.namespace; - var isValid = hasValidUser || hasValidOrg || hasValidRepo || $scope.allLogs; - - if (!$scope.makevisible || !isValid) { - return; - } - - var twoWeeksAgo = getOffsetDate($scope.logEndDate, -14); - if ($scope.logStartDate > $scope.logEndDate || $scope.logStartDate < twoWeeksAgo) { - $scope.logStartDate = twoWeeksAgo; - } - - $scope.loading = true; - - // Note: We construct the URLs here manually because we also use it for the download - // path. - var url = getRestUrl('user/logs'); - if ($scope.organization) { - url = getRestUrl('organization', $scope.organization.name, 'logs'); - } - if ($scope.repository) { - url = getRestUrl('repository', $scope.repository.namespace, $scope.repository.name, 'logs'); - } - - if ($scope.allLogs) { - url = getRestUrl('superuser', 'logs') - } - - url += '?starttime=' + encodeURIComponent(getDateString($scope.logStartDate)); - url += '&endtime=' + encodeURIComponent(getDateString($scope.logEndDate)); - - if ($scope.performer) { - url += '&performer=' + encodeURIComponent($scope.performer.name); - } - - var loadLogs = Restangular.one(url); - loadLogs.customGET().then(function(resp) { - $scope.logsPath = '/api/v1/' + url; - - if (!$scope.chart) { - $scope.chart = new LogUsageChart(logKinds); - $($scope.chart).bind('filteringChanged', function(e) { - $scope.$apply(function() { $scope.kindsAllowed = e.allowed; }); - }); - } - - $scope.chart.draw('bar-chart', resp.logs, $scope.logStartDate, $scope.logEndDate); - $scope.kindsAllowed = null; - $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) { - log.metadata['_ip'] = log.ip ? log.ip : null; - return StringBuilderService.buildString(logDescriptions[log.kind] || log.kind, log.metadata); - }; - - $scope.$watch('organization', update); - $scope.$watch('user', update); - $scope.$watch('repository', update); - $scope.$watch('makevisible', update); - $scope.$watch('performer', update); - $scope.$watch('logStartDate', update); - $scope.$watch('logEndDate', update); - } - }; - - return directiveDefinitionObject; -}); - - -quayApp.directive('applicationManager', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/application-manager.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'organization': '=organization', - 'makevisible': '=makevisible' - }, - controller: function($scope, $element, ApiService) { - $scope.loading = false; - $scope.applications = []; - - $scope.createApplication = function(appName) { - if (!appName) { return; } - - var params = { - 'orgname': $scope.organization.name - }; - - var data = { - 'name': appName - }; - - ApiService.createOrganizationApplication(data, params).then(function(resp) { - $scope.applications.push(resp); - }, ApiService.errorDisplay('Cannot create application')); - }; - - var update = function() { - if (!$scope.organization || !$scope.makevisible) { return; } - if ($scope.loading) { return; } - - $scope.loading = true; - - var params = { - 'orgname': $scope.organization.name - }; - - ApiService.getOrganizationApplications(null, params).then(function(resp) { - $scope.loading = false; - $scope.applications = resp['applications'] || []; - }); - }; - - $scope.$watch('organization', update); - $scope.$watch('makevisible', update); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('robotsManager', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/robots-manager.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'organization': '=organization', - 'user': '=user' - }, - controller: function($scope, $element, ApiService, $routeParams) { - $scope.ROBOT_PATTERN = ROBOT_PATTERN; - $scope.robots = null; - $scope.loading = false; - $scope.shownRobot = null; - $scope.showRobotCounter = 0; - - $scope.regenerateToken = function(username) { - if (!username) { return; } - - var shortName = $scope.getShortenedName(username); - ApiService.regenerateRobotToken($scope.organization, null, {'robot_shortname': shortName}).then(function(updated) { - var index = $scope.findRobotIndexByName(username); - if (index >= 0) { - $scope.robots.splice(index, 1); - $scope.robots.push(updated); - } - $scope.shownRobot = updated; - }, ApiService.errorDisplay('Cannot regenerate robot account token')); - }; - - $scope.showRobot = function(info) { - $scope.shownRobot = info; - $scope.showRobotCounter++; - }; - - $scope.findRobotIndexByName = function(name) { - for (var i = 0; i < $scope.robots.length; ++i) { - if ($scope.robots[i].name == name) { - return i; - } - } - return -1; - }; - - $scope.getShortenedName = function(name) { - var plus = name.indexOf('+'); - return name.substr(plus + 1); - }; - - $scope.getPrefix = function(name) { - var plus = name.indexOf('+'); - return name.substr(0, plus); - }; - - $scope.createRobot = function(name) { - if (!name) { return; } - - createRobotAccount(ApiService, !!$scope.organization, $scope.organization ? $scope.organization.name : '', name, - function(created) { - $scope.robots.push(created); - }); - }; - - $scope.deleteRobot = function(info) { - var shortName = $scope.getShortenedName(info.name); - ApiService.deleteRobot($scope.organization, null, {'robot_shortname': shortName}).then(function(resp) { - var index = $scope.findRobotIndexByName(info.name); - if (index >= 0) { - $scope.robots.splice(index, 1); - } - }, ApiService.errorDisplay('Cannot delete robot account')); - }; - - var update = function() { - if (!$scope.user && !$scope.organization) { return; } - if ($scope.loading) { return; } - - $scope.loading = true; - ApiService.getRobots($scope.organization).then(function(resp) { - $scope.robots = resp.robots; - $scope.loading = false; - - if ($routeParams.showRobot) { - var index = $scope.findRobotIndexByName($routeParams.showRobot); - if (index >= 0) { - $scope.showRobot($scope.robots[index]); - } - } - }); - }; - - $scope.$watch('organization', update); - $scope.$watch('user', update); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('prototypeManager', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/prototype-manager.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'organization': '=organization' - }, - controller: function($scope, $element, ApiService) { - $scope.loading = false; - $scope.activatingForNew = null; - $scope.delegateForNew = null; - $scope.clearCounter = 0; - $scope.newForWholeOrg = true; - - $scope.roles = [ - { 'id': 'read', 'title': 'Read', 'kind': 'success' }, - { 'id': 'write', 'title': 'Write', 'kind': 'success' }, - { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } - ]; - - $scope.setRole = function(role, prototype) { - var params = { - 'orgname': $scope.organization.name, - 'prototypeid': prototype.id - }; - - var data = { - 'id': prototype.id, - 'role': role - }; - - ApiService.updateOrganizationPrototypePermission(data, params).then(function(resp) { - prototype.role = role; - }, ApiService.errorDisplay('Cannot modify permission')); - }; - - $scope.comparePrototypes = function(p) { - return p.activating_user ? p.activating_user.name : ' '; - }; - - $scope.setRoleForNew = function(role) { - $scope.newRole = role; - }; - - $scope.setNewForWholeOrg = function(value) { - $scope.newForWholeOrg = value; - }; - - $scope.showAddDialog = function() { - $scope.activatingForNew = null; - $scope.delegateForNew = null; - $scope.newRole = 'read'; - $scope.clearCounter++; - $scope.newForWholeOrg = true; - $('#addPermissionDialogModal').modal({}); - }; - - $scope.createPrototype = function() { - $scope.loading = true; - - var params = { - 'orgname': $scope.organization.name - }; - - var data = { - 'delegate': $scope.delegateForNew, - 'role': $scope.newRole - }; - - if (!$scope.newForWholeOrg) { - data['activating_user'] = $scope.activatingForNew; - } - - var errorHandler = ApiService.errorDisplay('Cannot create permission', - function(resp) { - $('#addPermissionDialogModal').modal('hide'); - }); - - ApiService.createOrganizationPrototypePermission(data, params).then(function(resp) { - $scope.prototypes.push(resp); - $scope.loading = false; - $('#addPermissionDialogModal').modal('hide'); - }, errorHandler); - }; - - $scope.deletePrototype = function(prototype) { - $scope.loading = true; - - var params = { - 'orgname': $scope.organization.name, - 'prototypeid': prototype.id - }; - - ApiService.deleteOrganizationPrototypePermission(null, params).then(function(resp) { - $scope.prototypes.splice($scope.prototypes.indexOf(prototype), 1); - $scope.loading = false; - }, ApiService.errorDisplay('Cannot delete permission')); - }; - - var update = function() { - if (!$scope.organization) { return; } - if ($scope.loading) { return; } - - var params = {'orgname': $scope.organization.name}; - - $scope.loading = true; - ApiService.getOrganizationPrototypePermissions(null, params).then(function(resp) { - $scope.prototypes = resp.prototypes; - $scope.loading = false; - }); - }; - - $scope.$watch('organization', update); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('deleteUi', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/delete-ui.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'deleteTitle': '=deleteTitle', - 'buttonTitle': '=buttonTitle', - 'performDelete': '&performDelete' - }, - controller: function($scope, $element) { - $scope.buttonTitleInternal = $scope.buttonTitle || 'Delete'; - - $element.children().attr('tabindex', 0); - $scope.focus = function() { - $element[0].firstChild.focus(); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('popupInputButton', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/popup-input-button.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'placeholder': '=placeholder', - 'pattern': '=pattern', - 'submitted': '&submitted' - }, - controller: function($scope, $element) { - $scope.popupShown = function() { - setTimeout(function() { - var box = $('#input-box'); - box[0].value = ''; - box.focus(); - }, 40); - }; - - $scope.getRegexp = function(pattern) { - if (!pattern) { - pattern = '.*'; - } - return new RegExp(pattern); - }; - - $scope.inputSubmit = function() { - var box = $('#input-box'); - if (box.hasClass('ng-invalid')) { return; } - - var entered = box[0].value; - if (!entered) { - return; - } - - if ($scope.submitted) { - $scope.submitted({'value': entered}); - } - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('resourceView', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/resource-view.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'resource': '=resource', - 'errorMessage': '=errorMessage' - }, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('quaySpinner', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/spinner.html', - replace: false, - transclude: true, - restrict: 'C', - scope: {}, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('registryName', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/registry-name.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'isShort': '=isShort' - }, - controller: function($scope, $element, Config) { - $scope.name = $scope.isShort ? Config.REGISTRY_TITLE_SHORT : Config.REGISTRY_TITLE; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('organizationHeader', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/organization-header.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'organization': '=organization', - 'teamName': '=teamName', - 'clickable': '=clickable' - }, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('markdownInput', function () { - var counter = 0; - - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/markdown-input.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'content': '=content', - 'canWrite': '=canWrite', - 'contentChanged': '=contentChanged', - 'fieldTitle': '=fieldTitle' - }, - controller: function($scope, $element) { - var elm = $element[0]; - - $scope.id = (counter++); - - $scope.editContent = function() { - if (!$scope.canWrite) { return; } - - if (!$scope.markdownDescriptionEditor) { - var converter = Markdown.getSanitizingConverter(); - var editor = new Markdown.Editor(converter, '-description-' + $scope.id); - editor.run(); - $scope.markdownDescriptionEditor = editor; - } - - $('#wmd-input-description-' + $scope.id)[0].value = $scope.content; - $(elm).find('.modal').modal({}); - }; - - $scope.saveContent = function() { - $scope.content = $('#wmd-input-description-' + $scope.id)[0].value; - $(elm).find('.modal').modal('hide'); - - if ($scope.contentChanged) { - $scope.contentChanged($scope.content); - } - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('repoSearch', function () { - var number = 0; - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/repo-search.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - }, - controller: function($scope, $element, $location, UserService, Restangular) { - var searchToken = 0; - $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { - ++searchToken; - }, true); - - var repoHound = new Bloodhound({ - name: 'repositories', - remote: { - url: '/api/v1/find/repository?query=%QUERY', - replace: function (url, uriEncodedQuery) { - url = url.replace('%QUERY', uriEncodedQuery); - url += '&cb=' + searchToken; - return url; - }, - filter: function(data) { - var datums = []; - for (var i = 0; i < data.repositories.length; ++i) { - var repo = data.repositories[i]; - datums.push({ - 'value': repo.name, - 'tokens': [repo.name, repo.namespace], - 'repo': repo - }); - } - return datums; - } - }, - datumTokenizer: function(d) { - return Bloodhound.tokenizers.whitespace(d.val); - }, - queryTokenizer: Bloodhound.tokenizers.whitespace - }); - repoHound.initialize(); - - var element = $($element[0].childNodes[0]); - element.typeahead({ 'highlight': true }, { - source: repoHound.ttAdapter(), - templates: { - 'suggestion': function (datum) { - template = '
'; - template += '' - template += '' + datum.repo.namespace +'/' + datum.repo.name + '' - if (datum.repo.description) { - template += '' + getFirstTextLine(datum.repo.description) + '' - } - - template += '
' - return template; - } - } - }); - - element.on('typeahead:selected', function (e, datum) { - element.typeahead('val', ''); - $scope.$apply(function() { - $location.path('/repository/' + datum.repo.namespace + '/' + datum.repo.name); - }); - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('headerBar', function () { - var number = 0; - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/header-bar.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - }, - controller: function($scope, $element, $location, UserService, PlanService, ApiService, NotificationService, Config) { - $scope.notificationService = NotificationService; - - // Monitor any user changes and place the current user into the scope. - UserService.updateUserIn($scope); - - $scope.signout = function() { - ApiService.logout().then(function() { - UserService.load(); - $location.path('/'); - }); - }; - - $scope.appLinkTarget = function() { - if ($("div[ng-view]").length === 0) { - return "_self"; - } - return ""; - }; - - $scope.getEnterpriseLogo = function() { - if (!Config.ENTERPRISE_LOGO_URL) { - return '/static/img/quay-logo.png'; - } - - return Config.ENTERPRISE_LOGO_URL; - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('entitySearch', function () { - var number = 0; - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/entity-search.html', - replace: false, - transclude: false, - restrict: 'C', - require: '?ngModel', - link: function(scope, element, attr, ctrl) { - scope.ngModel = ctrl; - }, - scope: { - 'namespace': '=namespace', - 'placeholder': '=placeholder', - - // Default: ['user', 'team', 'robot'] - 'allowedEntities': '=allowedEntities', - - 'currentEntity': '=currentEntity', - - 'entitySelected': '&entitySelected', - 'emailSelected': '&emailSelected', - - // When set to true, the contents of the control will be cleared as soon - // as an entity is selected. - 'autoClear': '=autoClear', - - // Set this property to immediately clear the contents of the control. - 'clearValue': '=clearValue', - - // Whether e-mail addresses are allowed. - 'allowEmails': '=allowEmails', - 'emailMessage': '@emailMessage', - - // True if the menu should pull right. - 'pullRight': '@pullRight' - }, - controller: function($rootScope, $scope, $element, Restangular, UserService, ApiService, UtilService, Config) { - $scope.lazyLoading = true; - - $scope.teams = null; - $scope.robots = null; - - $scope.isAdmin = false; - $scope.isOrganization = false; - - $scope.includeTeams = true; - $scope.includeRobots = true; - $scope.includeOrgs = false; - - $scope.currentEntityInternal = $scope.currentEntity; - - var isSupported = function(kind, opt_array) { - return $.inArray(kind, opt_array || $scope.allowedEntities || ['user', 'team', 'robot']) >= 0; - }; - - $scope.lazyLoad = function() { - if (!$scope.namespace || !$scope.lazyLoading) { return; } - - // Reset the cached teams and robots. - $scope.teams = null; - $scope.robots = null; - - // Load the organization's teams (if applicable). - if ($scope.isOrganization && isSupported('team')) { - // Note: We load the org here again so that we always have the fully up-to-date - // teams list. - ApiService.getOrganization(null, {'orgname': $scope.namespace}).then(function(resp) { - $scope.teams = resp.teams; - }); - } - - // Load the user/organization's robots (if applicable). - if ($scope.isAdmin && isSupported('robot')) { - ApiService.getRobots($scope.isOrganization ? $scope.namespace : null).then(function(resp) { - $scope.robots = resp.robots; - $scope.lazyLoading = false; - }, function() { - $scope.lazyLoading = false; - }); - } else { - $scope.lazyLoading = false; - } - }; - - $scope.createTeam = function() { - if (!$scope.isAdmin) { return; } - - bootbox.prompt('Enter the name of the new team', function(teamname) { - if (!teamname) { return; } - - var regex = new RegExp(TEAM_PATTERN); - if (!regex.test(teamname)) { - bootbox.alert('Invalid team name'); - return; - } - - createOrganizationTeam(ApiService, $scope.namespace, teamname, function(created) { - $scope.setEntity(created.name, 'team', false); - $scope.teams[teamname] = created; - }); - }); - }; - - $scope.createRobot = function() { - if (!$scope.isAdmin) { return; } - - bootbox.prompt('Enter the name of the new robot account', function(robotname) { - if (!robotname) { return; } - - var regex = new RegExp(ROBOT_PATTERN); - if (!regex.test(robotname)) { - bootbox.alert('Invalid robot account name'); - return; - } - - createRobotAccount(ApiService, $scope.isOrganization, $scope.namespace, robotname, function(created) { - $scope.setEntity(created.name, 'user', true); - $scope.robots.push(created); - }); - }); - }; - - $scope.setEntity = function(name, kind, is_robot) { - var entity = { - 'name': name, - 'kind': kind, - 'is_robot': is_robot - }; - - if ($scope.isOrganization) { - entity['is_org_member'] = true; - } - - $scope.setEntityInternal(entity, false); - }; - - $scope.clearEntityInternal = function() { - $scope.currentEntityInternal = null; - $scope.currentEntity = null; - $scope.entitySelected({'entity': null}); - if ($scope.ngModel) { - $scope.ngModel.$setValidity('entity', false); - } - }; - - $scope.setEntityInternal = function(entity, updateTypeahead) { - if (updateTypeahead) { - $(input).typeahead('val', $scope.autoClear ? '' : entity.name); - } else { - $(input).val($scope.autoClear ? '' : entity.name); - } - - if (!$scope.autoClear) { - $scope.currentEntityInternal = entity; - $scope.currentEntity = entity; - } - - $scope.entitySelected({'entity': entity}); - if ($scope.ngModel) { - $scope.ngModel.$setValidity('entity', !!entity); - } - }; - - // Setup the typeahead. - var input = $element[0].firstChild.firstChild; - - (function() { - // Create the bloodhound search query system. - $rootScope.__entity_search_counter = (($rootScope.__entity_search_counter || 0) + 1); - var entitySearchB = new Bloodhound({ - name: 'entities' + $rootScope.__entity_search_counter, - remote: { - url: '/api/v1/entities/%QUERY', - replace: function (url, uriEncodedQuery) { - var namespace = $scope.namespace || ''; - url = url.replace('%QUERY', uriEncodedQuery); - url += '?namespace=' + encodeURIComponent(namespace); - if ($scope.isOrganization && isSupported('team')) { - url += '&includeTeams=true' - } - if (isSupported('org')) { - url += '&includeOrgs=true' - } - return url; - }, - filter: function(data) { - var datums = []; - for (var i = 0; i < data.results.length; ++i) { - var entity = data.results[i]; - - var found = 'user'; - if (entity.kind == 'user') { - found = entity.is_robot ? 'robot' : 'user'; - } else if (entity.kind == 'team') { - found = 'team'; - } else if (entity.kind == 'org') { - found = 'org'; - } - - if (!isSupported(found)) { - continue; - } - - datums.push({ - 'value': entity.name, - 'tokens': [entity.name], - 'entity': entity - }); - } - return datums; - } - }, - datumTokenizer: function(d) { - return Bloodhound.tokenizers.whitespace(d.val); - }, - queryTokenizer: Bloodhound.tokenizers.whitespace - }); - entitySearchB.initialize(); - - // Setup the typeahead. - $(input).typeahead({ - 'highlight': true - }, { - source: entitySearchB.ttAdapter(), - templates: { - 'empty': function(info) { - // Only display the empty dialog if the server load has finished. - if (info.resultKind == 'remote') { - var val = $(input).val(); - if (!val) { - return null; - } - - if (UtilService.isEmailAddress(val)) { - if ($scope.allowEmails) { - return '
' + $scope.emailMessage + '
'; - } else { - return '
A ' + Config.REGISTRY_TITLE_SHORT + ' username (not an e-mail address) must be specified
'; - } - } - - var classes = []; - - if (isSupported('user')) { classes.push('users'); } - if (isSupported('org')) { classes.push('organizations'); } - if ($scope.isAdmin && isSupported('robot')) { classes.push('robot accounts'); } - if ($scope.isOrganization && isSupported('team')) { classes.push('teams'); } - - if (classes.length > 1) { - classes[classes.length - 1] = 'or ' + classes[classes.length - 1]; - } else if (classes.length == 0) { - return '
No matching entities found
'; - } - - var class_string = ''; - for (var i = 0; i < classes.length; ++i) { - if (i > 0) { - if (i == classes.length - 1) { - class_string += ' or '; - } else { - class_string += ', '; - } - } - - class_string += classes[i]; - } - - return '
No matching ' + Config.REGISTRY_TITLE_SHORT + ' ' + class_string + ' found
'; - } - - return null; - }, - 'suggestion': function (datum) { - template = '
'; - if (datum.entity.kind == 'user' && !datum.entity.is_robot) { - template += ''; - } else if (datum.entity.kind == 'user' && datum.entity.is_robot) { - template += ''; - } else if (datum.entity.kind == 'team') { - template += ''; - } else if (datum.entity.kind == 'org') { - template += '' + AvatarService.getAvatar(datum.entity.avatar, 16) + ''; - } - - template += '' + datum.value + ''; - - if (datum.entity.is_org_member === false && datum.entity.kind == 'user') { - template += ''; - } - - template += '
'; - return template; - }} - }); - - $(input).on('keypress', function(e) { - var val = $(input).val(); - var code = e.keyCode || e.which; - if (code == 13 && $scope.allowEmails && UtilService.isEmailAddress(val)) { - $scope.$apply(function() { - $scope.emailSelected({'email': val}); - }); - } - }); - - $(input).on('input', function(e) { - $scope.$apply(function() { - $scope.clearEntityInternal(); - }); - }); - - $(input).on('typeahead:selected', function(e, datum) { - $scope.$apply(function() { - $scope.setEntityInternal(datum.entity, true); - }); - }); - })(); - - $scope.$watch('clearValue', function() { - if (!input) { return; } - - $(input).typeahead('val', ''); - $scope.clearEntityInternal(); - }); - - $scope.$watch('placeholder', function(title) { - input.setAttribute('placeholder', title); - }); - - $scope.$watch('allowedEntities', function(allowed) { - if (!allowed) { return; } - $scope.includeTeams = isSupported('team', allowed); - $scope.includeRobots = isSupported('robot', allowed); - }); - - $scope.$watch('namespace', function(namespace) { - if (!namespace) { return; } - $scope.isAdmin = UserService.isNamespaceAdmin(namespace); - $scope.isOrganization = !!UserService.getOrganization(namespace); - }); - - $scope.$watch('currentEntity', function(entity) { - if ($scope.currentEntityInternal != entity) { - if (entity) { - $scope.setEntityInternal(entity, false); - } else { - $scope.clearEntityInternal(); - } - } - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('roleGroup', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/role-group.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'roles': '=roles', - 'currentRole': '=currentRole', - 'roleChanged': '&roleChanged' - }, - controller: function($scope, $element) { - $scope.setRole = function(role) { - if ($scope.currentRole == role) { return; } - if ($scope.roleChanged) { - $scope.roleChanged({'role': role}); - } else { - $scope.currentRole = role; - } - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('billingOptions', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/billing-options.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'user': '=user', - 'organization': '=organization' - }, - controller: function($scope, $element, PlanService, ApiService) { - $scope.invoice_email = false; - $scope.currentCard = null; - - // Listen to plan changes. - PlanService.registerListener(this, function(plan) { - if (plan && plan.price > 0) { - update(); - } - }); - - $scope.$on('$destroy', function() { - PlanService.unregisterListener(this); - }); - - $scope.isExpiringSoon = function(cardInfo) { - var current = new Date(); - var expires = new Date(cardInfo.exp_year, cardInfo.exp_month, 1); - var difference = expires - current; - return difference < (60 * 60 * 24 * 60 * 1000 /* 60 days */); - }; - - $scope.changeCard = function() { - var previousCard = $scope.currentCard; - $scope.changingCard = true; - var callbacks = { - 'opened': function() { $scope.changingCard = true; }, - 'closed': function() { $scope.changingCard = false; }, - 'started': function() { $scope.currentCard = null; }, - 'success': function(resp) { - $scope.currentCard = resp.card; - $scope.changingCard = false; - }, - 'failure': function(resp) { - $scope.changingCard = false; - $scope.currentCard = previousCard; - - if (!PlanService.isCardError(resp)) { - $('#cannotchangecardModal').modal({}); - } - } - }; - - PlanService.changeCreditCard($scope, $scope.organization ? $scope.organization.name : null, callbacks); - }; - - $scope.getCreditImage = function(creditInfo) { - if (!creditInfo || !creditInfo.type) { return 'credit.png'; } - - var kind = creditInfo.type.toLowerCase() || 'credit'; - var supported = { - 'american express': 'amex', - 'credit': 'credit', - 'diners club': 'diners', - 'discover': 'discover', - 'jcb': 'jcb', - 'mastercard': 'mastercard', - 'visa': 'visa' - }; - - kind = supported[kind] || 'credit'; - return kind + '.png'; - }; - - var update = function() { - if (!$scope.user && !$scope.organization) { return; } - $scope.obj = $scope.user ? $scope.user : $scope.organization; - $scope.invoice_email = $scope.obj.invoice_email; - - // Load the credit card information. - PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) { - $scope.currentCard = card; - }); - }; - - var save = function() { - $scope.working = true; - - var errorHandler = ApiService.errorDisplay('Could not change user details'); - ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) { - $scope.working = false; - }, errorHandler); - }; - - var checkSave = function() { - if (!$scope.obj) { return; } - if ($scope.obj.invoice_email != $scope.invoice_email) { - $scope.obj.invoice_email = $scope.invoice_email; - save(); - } - }; - - $scope.$watch('invoice_email', checkSave); - $scope.$watch('organization', update); - $scope.$watch('user', update); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('planManager', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/plan-manager.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'user': '=user', - 'organization': '=organization', - 'readyForPlan': '&readyForPlan', - 'planChanged': '&planChanged' - }, - controller: function($scope, $element, PlanService, ApiService) { - $scope.isExistingCustomer = false; - - $scope.parseDate = function(timestamp) { - return new Date(timestamp * 1000); - }; - - $scope.isPlanVisible = function(plan, subscribedPlan) { - if (plan['deprecated']) { - return plan == subscribedPlan; - } - - if ($scope.organization && !PlanService.isOrgCompatible(plan)) { - return false; - } - - return true; - }; - - $scope.changeSubscription = function(planId, opt_async) { - if ($scope.planChanging) { return; } - - var callbacks = { - 'opening': function() { $scope.planChanging = true; }, - 'started': function() { $scope.planChanging = true; }, - 'opened': function() { $scope.planChanging = true; }, - 'closed': function() { $scope.planChanging = false; }, - 'success': subscribedToPlan, - 'failure': function(resp) { - $scope.planChanging = false; - } - }; - - PlanService.changePlan($scope, $scope.organization, planId, callbacks, opt_async); - }; - - $scope.cancelSubscription = function() { - $scope.changeSubscription(PlanService.getFreePlan()); - }; - - var subscribedToPlan = function(sub) { - $scope.subscription = sub; - $scope.isExistingCustomer = !!sub['isExistingCustomer']; - - PlanService.getPlanIncludingDeprecated(sub.plan, function(subscribedPlan) { - $scope.subscribedPlan = subscribedPlan; - $scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos; - - if ($scope.planChanged) { - $scope.planChanged({ 'plan': subscribedPlan }); - } - - $scope.planChanging = false; - $scope.planLoading = false; - }); - }; - - var update = function() { - $scope.planLoading = true; - if (!$scope.plans) { return; } - - PlanService.getSubscription($scope.organization, subscribedToPlan, function() { - $scope.isExistingCustomer = false; - subscribedToPlan({ 'plan': PlanService.getFreePlan() }); - }); - }; - - var loadPlans = function() { - if ($scope.plans || $scope.loadingPlans) { return; } - if (!$scope.user && !$scope.organization) { return; } - - $scope.loadingPlans = true; - PlanService.verifyLoaded(function(plans) { - $scope.plans = plans; - update(); - - if ($scope.readyForPlan) { - var planRequested = $scope.readyForPlan(); - if (planRequested && planRequested != PlanService.getFreePlan()) { - $scope.changeSubscription(planRequested, /* async */true); - } - } - }); - }; - - // Start the initial download. - $scope.planLoading = true; - loadPlans(); - - $scope.$watch('organization', loadPlans); - $scope.$watch('user', loadPlans); - } - }; - return directiveDefinitionObject; -}); - - - -quayApp.directive('namespaceSelector', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/namespace-selector.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'user': '=user', - 'namespace': '=namespace', - 'requireCreate': '=requireCreate' - }, - controller: function($scope, $element, $routeParams, $location, CookieService) { - $scope.namespaces = {}; - - $scope.initialize = function(user) { - var preferredNamespace = user.username; - var namespaces = {}; - namespaces[user.username] = user; - if (user.organizations) { - for (var i = 0; i < user.organizations.length; ++i) { - namespaces[user.organizations[i].name] = user.organizations[i]; - if (user.organizations[i].preferred_namespace) { - preferredNamespace = user.organizations[i].name; - } - } - } - - var initialNamespace = $routeParams['namespace'] || CookieService.get('quay.namespace') || - preferredNamespace || $scope.user.username; - $scope.namespaces = namespaces; - $scope.setNamespace($scope.namespaces[initialNamespace]); - }; - - $scope.setNamespace = function(namespaceObj) { - if (!namespaceObj) { - namespaceObj = $scope.namespaces[$scope.user.username]; - } - - if ($scope.requireCreate && !namespaceObj.can_create_repo) { - namespaceObj = $scope.namespaces[$scope.user.username]; - } - - var newNamespace = namespaceObj.name || namespaceObj.username; - $scope.namespaceObj = namespaceObj; - $scope.namespace = newNamespace; - - if (newNamespace) { - CookieService.putPermanent('quay.namespace', newNamespace); - - if ($routeParams['namespace'] && $routeParams['namespace'] != newNamespace) { - $location.search({'namespace': newNamespace}); - } - } - }; - - $scope.$watch('namespace', function(namespace) { - if ($scope.namespaceObj && namespace && namespace != $scope.namespaceObj.username) { - $scope.setNamespace($scope.namespaces[namespace]); - } - }); - - $scope.$watch('user', function(user) { - $scope.user = user; - $scope.initialize(user); - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('buildLogPhase', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/build-log-phase.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'phase': '=phase' - }, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('buildLogError', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/build-log-error.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'error': '=error', - 'entries': '=entries' - }, - controller: function($scope, $element, Config) { - $scope.isInternalError = function() { - var entry = $scope.entries[$scope.entries.length - 1]; - return entry && entry.data && entry.data['internal_error']; - }; - - $scope.getLocalPullInfo = function() { - if ($scope.entries.__localpull !== undefined) { - return $scope.entries.__localpull; - } - - var localInfo = { - 'isLocal': false - }; - - // Find the 'pulling' phase entry, and then extra any metadata found under - // it. - for (var i = 0; i < $scope.entries.length; ++i) { - var entry = $scope.entries[i]; - if (entry.type == 'phase' && entry.message == 'pulling') { - for (var j = 0; j < entry.logs.length(); ++j) { - var log = entry.logs.get(j); - if (log.data && log.data.phasestep == 'login') { - localInfo['login'] = log.data; - } - - if (log.data && log.data.phasestep == 'pull') { - var repo_url = log.data['repo_url']; - var repo_and_tag = repo_url.substring(Config.SERVER_HOSTNAME.length + 1); - var tagIndex = repo_and_tag.lastIndexOf(':'); - var repo = repo_and_tag.substring(0, tagIndex); - - localInfo['repo_url'] = repo_url; - localInfo['repo'] = repo; - - localInfo['isLocal'] = repo_url.indexOf(Config.SERVER_HOSTNAME + '/') == 0; - } - } - break; - } - } - - return $scope.entries.__localpull = localInfo; - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('triggerDescription', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/trigger-description.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'trigger': '=trigger', - 'short': '=short' - }, - controller: function($scope, $element, KeyService, TriggerService) { - $scope.KeyService = KeyService; - $scope.TriggerService = TriggerService; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('stepView', function ($compile) { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/step-view.html', - replace: true, - transclude: true, - restrict: 'C', - scope: { - 'nextStepCounter': '=nextStepCounter', - 'currentStepValid': '=currentStepValid', - - 'stepsCompleted': '&stepsCompleted' - }, - controller: function($scope, $element, $rootScope) { - this.currentStepIndex = -1; - this.steps = []; - this.watcher = null; - - this.getCurrentStep = function() { - return this.steps[this.currentStepIndex]; - }; - - this.reset = function() { - this.currentStepIndex = -1; - for (var i = 0; i < this.steps.length; ++i) { - this.steps[i].element.hide(); - } - - $scope.currentStepValid = false; - }; - - this.next = function() { - if (this.currentStepIndex >= 0) { - var currentStep = this.getCurrentStep(); - if (!currentStep || !currentStep.scope) { return; } - - if (!currentStep.scope.completeCondition) { - return; - } - - currentStep.element.hide(); - - if (this.unwatch) { - this.unwatch(); - this.unwatch = null; - } - } - - this.currentStepIndex++; - - if (this.currentStepIndex < this.steps.length) { - var currentStep = this.getCurrentStep(); - currentStep.element.show(); - currentStep.scope.load() - - this.unwatch = currentStep.scope.$watch('completeCondition', function(cc) { - $scope.currentStepValid = !!cc; - }); - } else { - $scope.stepsCompleted(); - } - }; - - this.register = function(scope, element) { - element.hide(); - - this.steps.push({ - 'scope': scope, - 'element': element - }); - }; - - var that = this; - $scope.$watch('nextStepCounter', function(nsc) { - if (nsc >= 0) { - that.next(); - } else { - that.reset(); - } - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('stepViewStep', function () { - var directiveDefinitionObject = { - priority: 1, - require: '^stepView', - templateUrl: '/static/directives/step-view-step.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'completeCondition': '=completeCondition', - 'loadCallback': '&loadCallback', - 'loadMessage': '@loadMessage' - }, - link: function(scope, element, attrs, controller) { - controller.register(scope, element); - }, - controller: function($scope, $element) { - $scope.load = function() { - $scope.loading = true; - $scope.loadCallback({'callback': function() { - $scope.loading = false; - }}); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('dropdownSelect', function ($compile) { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/dropdown-select.html', - replace: true, - transclude: true, - restrict: 'C', - scope: { - 'selectedItem': '=selectedItem', - 'placeholder': '=placeholder', - 'lookaheadItems': '=lookaheadItems', - - 'allowCustomInput': '@allowCustomInput', - - 'handleItemSelected': '&handleItemSelected', - 'handleInput': '&handleInput', - - 'clearValue': '=clearValue' - }, - controller: function($scope, $element, $rootScope) { - if (!$rootScope.__dropdownSelectCounter) { - $rootScope.__dropdownSelectCounter = 1; - } - - $scope.placeholder = $scope.placeholder || ''; - $scope.internalItem = null; - - // Setup lookahead. - var input = $($element).find('.lookahead-input'); - - $scope.$watch('clearValue', function(cv) { - if (cv) { - $scope.selectedItem = null; - $(input).val(''); - } - }); - - $scope.$watch('selectedItem', function(item) { - if ($scope.selectedItem == $scope.internalItem) { - // The item has already been set due to an internal action. - return; - } - - if ($scope.selectedItem != null) { - $(input).val(item.toString()); - } else { - $(input).val(''); - } - }); - - $scope.$watch('lookaheadItems', function(items) { - $(input).off(); - if (!items) { - return; - } - - var formattedItems = []; - for (var i = 0; i < items.length; ++i) { - var formattedItem = items[i]; - if (typeof formattedItem == 'string') { - formattedItem = { - 'value': formattedItem - }; - } - formattedItems.push(formattedItem); - } - - var dropdownHound = new Bloodhound({ - name: 'dropdown-items-' + $rootScope.__dropdownSelectCounter, - local: formattedItems, - datumTokenizer: function(d) { - return Bloodhound.tokenizers.whitespace(d.val || d.value || ''); - }, - queryTokenizer: Bloodhound.tokenizers.whitespace - }); - dropdownHound.initialize(); - - $(input).typeahead({}, { - source: dropdownHound.ttAdapter(), - templates: { - 'suggestion': function (datum) { - template = datum['template'] ? datum['template'](datum) : datum['value']; - return template; - } - } - }); - - $(input).on('input', function(e) { - $scope.$apply(function() { - $scope.internalItem = null; - $scope.selectedItem = null; - if ($scope.handleInput) { - $scope.handleInput({'input': $(input).val()}); - } - }); - }); - - $(input).on('typeahead:selected', function(e, datum) { - $scope.$apply(function() { - $scope.internalItem = datum['item'] || datum['value']; - $scope.selectedItem = datum['item'] || datum['value']; - if ($scope.handleItemSelected) { - $scope.handleItemSelected({'datum': datum}); - } - }); - }); - - $rootScope.__dropdownSelectCounter++; - }); - }, - link: function(scope, element, attrs) { - var transcludedBlock = element.find('div.transcluded'); - var transcludedElements = transcludedBlock.children(); - - var iconContainer = element.find('div.dropdown-select-icon-transclude'); - var menuContainer = element.find('div.dropdown-select-menu-transclude'); - - angular.forEach(transcludedElements, function(elem) { - if (angular.element(elem).hasClass('dropdown-select-icon')) { - iconContainer.append(elem); - } else if (angular.element(elem).hasClass('dropdown-select-menu')) { - menuContainer.replaceWith(elem); - } - }); - - transcludedBlock.remove(); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('dropdownSelectIcon', function () { - var directiveDefinitionObject = { - priority: 1, - require: '^dropdownSelect', - templateUrl: '/static/directives/dropdown-select-icon.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - }, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('dropdownSelectMenu', function () { - var directiveDefinitionObject = { - priority: 1, - require: '^dropdownSelect', - templateUrl: '/static/directives/dropdown-select-menu.html', - replace: true, - transclude: true, - restrict: 'C', - scope: { - }, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('manualTriggerBuildDialog', function () { - var directiveDefinitionObject = { - templateUrl: '/static/directives/manual-trigger-build-dialog.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'repository': '=repository', - 'counter': '=counter', - 'trigger': '=trigger', - 'startBuild': '&startBuild' - }, - controller: function($scope, $element, ApiService, TriggerService) { - $scope.parameters = {}; - $scope.fieldOptions = {}; - - $scope.startTrigger = function() { - $('#startTriggerDialog').modal('hide'); - $scope.startBuild({ - 'trigger': $scope.trigger, - 'parameters': $scope.parameters - }); - }; - - $scope.show = function() { - $scope.parameters = {}; - $scope.fieldOptions = {}; - - var parameters = TriggerService.getRunParameters($scope.trigger.service); - for (var i = 0; i < parameters.length; ++i) { - var parameter = parameters[i]; - if (parameter['type'] == 'option') { - // Load the values for this parameter. - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'trigger_uuid': $scope.trigger.id, - 'field_name': parameter['name'] - }; - - ApiService.listTriggerFieldValues(null, params).then(function(resp) { - $scope.fieldOptions[parameter['name']] = resp['values']; - }); - } - } - $scope.runParameters = parameters; - - $('#startTriggerDialog').modal('show'); - }; - - $scope.$watch('counter', function(counter) { - if (counter) { - $scope.show(); - } - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('setupTriggerDialog', function () { - var directiveDefinitionObject = { - templateUrl: '/static/directives/setup-trigger-dialog.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'repository': '=repository', - 'trigger': '=trigger', - 'counter': '=counter', - 'canceled': '&canceled', - 'activated': '&activated' - }, - controller: function($scope, $element, ApiService, UserService) { - var modalSetup = false; - - $scope.state = {}; - $scope.nextStepCounter = -1; - $scope.currentView = 'config'; - - $scope.show = function() { - if (!$scope.trigger || !$scope.repository) { return; } - - $scope.currentView = 'config'; - $('#setupTriggerModal').modal({}); - - if (!modalSetup) { - $('#setupTriggerModal').on('hidden.bs.modal', function () { - if (!$scope.trigger || $scope.trigger['is_active']) { return; } - - $scope.nextStepCounter = -1; - $scope.$apply(function() { - $scope.cancelSetupTrigger(); - }); - }); - - modalSetup = true; - $scope.nextStepCounter = 0; - } - }; - - $scope.isNamespaceAdmin = function(namespace) { - return UserService.isNamespaceAdmin(namespace); - }; - - $scope.cancelSetupTrigger = function() { - $scope.canceled({'trigger': $scope.trigger}); - }; - - $scope.hide = function() { - $('#setupTriggerModal').modal('hide'); - }; - - $scope.checkAnalyze = function(isValid) { - $scope.currentView = 'analyzing'; - $scope.pullInfo = { - 'is_public': true - }; - - if (!isValid) { - $scope.currentView = 'analyzed'; - return; - } - - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'trigger_uuid': $scope.trigger.id - }; - - var data = { - 'config': $scope.trigger.config - }; - - ApiService.analyzeBuildTrigger(data, params).then(function(resp) { - $scope.currentView = 'analyzed'; - - if (resp['status'] == 'analyzed') { - if (resp['robots'] && resp['robots'].length > 0) { - $scope.pullInfo['pull_entity'] = resp['robots'][0]; - } else { - $scope.pullInfo['pull_entity'] = null; - } - - $scope.pullInfo['is_public'] = false; - } - - $scope.pullInfo['analysis'] = resp; - }, ApiService.errorDisplay('Cannot load Dockerfile information')); - }; - - $scope.activate = function() { - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'trigger_uuid': $scope.trigger.id - }; - - var data = { - 'config': $scope.trigger['config'] - }; - - if ($scope.pullInfo['pull_entity']) { - data['pull_robot'] = $scope.pullInfo['pull_entity']['name']; - } - - $scope.currentView = 'activating'; - - var errorHandler = ApiService.errorDisplay('Cannot activate build trigger', function(resp) { - $scope.hide(); - $scope.canceled({'trigger': $scope.trigger}); - }); - - ApiService.activateBuildTrigger(data, params).then(function(resp) { - $scope.hide(); - $scope.trigger['is_active'] = true; - $scope.trigger['pull_robot'] = resp['pull_robot']; - $scope.activated({'trigger': $scope.trigger}); - }, errorHandler); - }; - - var check = function() { - if ($scope.counter && $scope.trigger && $scope.repository) { - $scope.show(); - } - }; - - $scope.$watch('trigger', check); - $scope.$watch('counter', check); - $scope.$watch('repository', check); - } - }; - return directiveDefinitionObject; -}); - - - -quayApp.directive('triggerSetupGithub', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/trigger-setup-github.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'repository': '=repository', - 'trigger': '=trigger', - - 'nextStepCounter': '=nextStepCounter', - 'currentStepValid': '=currentStepValid', - - 'analyze': '&analyze' - }, - controller: function($scope, $element, ApiService) { - $scope.analyzeCounter = 0; - $scope.setupReady = false; - $scope.refs = null; - $scope.branchNames = null; - $scope.tagNames = null; - - $scope.state = { - 'currentRepo': null, - 'branchTagFilter': '', - 'hasBranchTagFilter': false, - 'isInvalidLocation': true, - 'currentLocation': null - }; - - $scope.isMatching = function(kind, name, filter) { - try { - var patt = new RegExp(filter); - } catch (ex) { - return false; - } - - var fullname = (kind + '/' + name); - var m = fullname.match(patt); - return m && m[0].length == fullname.length; - } - - $scope.addRef = function(kind, name) { - if ($scope.isMatching(kind, name, $scope.state.branchTagFilter)) { - return; - } - - var newFilter = kind + '/' + name; - var existing = $scope.state.branchTagFilter; - if (existing) { - $scope.state.branchTagFilter = '(' + existing + ')|(' + newFilter + ')'; - } else { - $scope.state.branchTagFilter = newFilter; - } - } - - $scope.stepsCompleted = function() { - $scope.analyze({'isValid': !$scope.state.isInvalidLocation}); - }; - - $scope.loadRepositories = function(callback) { - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'trigger_uuid': $scope.trigger.id - }; - - ApiService.listTriggerBuildSources(null, params).then(function(resp) { - $scope.orgs = resp['sources']; - setupTypeahead(); - callback(); - }, ApiService.errorDisplay('Cannot load repositories')); - }; - - $scope.loadBranchesAndTags = function(callback) { - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'trigger_uuid': $scope.trigger['id'], - 'field_name': 'refs' - }; - - ApiService.listTriggerFieldValues($scope.trigger['config'], params).then(function(resp) { - $scope.refs = resp['values']; - $scope.branchNames = []; - $scope.tagNames = []; - - for (var i = 0; i < $scope.refs.length; ++i) { - var ref = $scope.refs[i]; - if (ref.kind == 'branch') { - $scope.branchNames.push(ref.name); - } else { - $scope.tagNames.push(ref.name); - } - } - - callback(); - }, ApiService.errorDisplay('Cannot load branch and tag names')); - }; - - $scope.loadLocations = function(callback) { - $scope.locations = null; - - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'trigger_uuid': $scope.trigger.id - }; - - ApiService.listBuildTriggerSubdirs($scope.trigger['config'], params).then(function(resp) { - if (resp['status'] == 'error') { - callback(resp['message'] || 'Could not load Dockerfile locations'); - return; - } - - $scope.locations = resp['subdir'] || []; - - // Select a default location (if any). - if ($scope.locations.length > 0) { - $scope.setLocation($scope.locations[0]); - } else { - $scope.state.currentLocation = null; - $scope.state.isInvalidLocation = resp['subdir'].indexOf('') < 0; - $scope.trigger.$ready = true; - } - - callback(); - }, ApiService.errorDisplay('Cannot load locations')); - } - - $scope.handleLocationInput = function(location) { - $scope.state.isInvalidLocation = $scope.locations.indexOf(location) < 0; - $scope.trigger['config']['subdir'] = location || ''; - $scope.trigger.$ready = true; - }; - - $scope.handleLocationSelected = function(datum) { - $scope.setLocation(datum['value']); - }; - - $scope.setLocation = function(location) { - $scope.state.currentLocation = location; - $scope.state.isInvalidLocation = false; - $scope.trigger['config']['subdir'] = location || ''; - $scope.trigger.$ready = true; - }; - - $scope.selectRepo = function(repo, org) { - $scope.state.currentRepo = { - 'repo': repo, - 'avatar_url': org['info']['avatar_url'], - 'toString': function() { - return this.repo; - } - }; - }; - - $scope.selectRepoInternal = function(currentRepo) { - $scope.trigger.$ready = false; - - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'trigger_uuid': $scope.trigger['id'] - }; - - var repo = currentRepo['repo']; - $scope.trigger['config'] = { - 'build_source': repo, - 'subdir': '' - }; - }; - - var setupTypeahead = function() { - var repos = []; - for (var i = 0; i < $scope.orgs.length; ++i) { - var org = $scope.orgs[i]; - var orepos = org['repos']; - for (var j = 0; j < orepos.length; ++j) { - var repoValue = { - 'repo': orepos[j], - 'avatar_url': org['info']['avatar_url'], - 'toString': function() { - return this.repo; - } - }; - var datum = { - 'name': orepos[j], - 'org': org, - 'value': orepos[j], - 'title': orepos[j], - 'item': repoValue - }; - repos.push(datum); - } - } - - $scope.repoLookahead = repos; - }; - - $scope.$watch('state.currentRepo', function(repo) { - if (repo) { - $scope.selectRepoInternal(repo); - } - }); - - $scope.$watch('state.branchTagFilter', function(bf) { - if (!$scope.trigger) { return; } - - if ($scope.state.hasBranchTagFilter) { - $scope.trigger['config']['branchtag_regex'] = bf; - } else { - delete $scope.trigger['config']['branchtag_regex']; - } - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('buildLogCommand', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/build-log-command.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'command': '=command' - }, - controller: function($scope, $element) { - $scope.getWithoutStep = function(fullTitle) { - var colon = fullTitle.indexOf(':'); - if (colon <= 0) { - return ''; - } - - return $.trim(fullTitle.substring(colon + 1)); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('dockerfileCommand', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/dockerfile-command.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'command': '=command' - }, - controller: function($scope, $element, UtilService, Config) { - var registryHandlers = { - 'quay.io': function(pieces) { - var rnamespace = pieces[pieces.length - 2]; - var rname = pieces[pieces.length - 1].split(':')[0]; - return '/repository/' + rnamespace + '/' + rname + '/'; - }, - - '': function(pieces) { - var rnamespace = pieces.length == 1 ? '_' : 'u/' + pieces[0]; - var rname = pieces[pieces.length - 1].split(':')[0]; - return 'https://registry.hub.docker.com/' + rnamespace + '/' + rname + '/'; - } - }; - - registryHandlers[Config.getDomain()] = registryHandlers['quay.io']; - - var kindHandlers = { - 'FROM': function(title) { - var pieces = title.split('/'); - var registry = pieces.length < 3 ? '' : pieces[0]; - if (!registryHandlers[registry]) { - return title; - } - - return ' ' + title + ''; - } - }; - - $scope.getCommandKind = function(title) { - var space = title.indexOf(' '); - return title.substring(0, space); - }; - - $scope.getCommandTitleHtml = function(title) { - var space = title.indexOf(' '); - if (space <= 0) { - return UtilService.textToSafeHtml(title); - } - - var kind = $scope.getCommandKind(title); - var sanitized = UtilService.textToSafeHtml(title.substring(space + 1)); - - var handler = kindHandlers[kind || '']; - if (handler) { - return handler(sanitized); - } else { - return sanitized; - } - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('dockerfileView', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/dockerfile-view.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'contents': '=contents' - }, - controller: function($scope, $element, UtilService) { - $scope.$watch('contents', function(contents) { - $scope.lines = []; - - var lines = contents ? contents.split('\n') : []; - for (var i = 0; i < lines.length; ++i) { - var line = $.trim(lines[i]); - var kind = 'text'; - if (line && line[0] == '#') { - kind = 'comment'; - } else if (line.match(/^([A-Z]+\s)/)) { - kind = 'command'; - } - - var lineInfo = { - 'text': line, - 'kind': kind - }; - $scope.lines.push(lineInfo); - } - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('buildStatus', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/build-status.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'build': '=build' - }, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('buildMessage', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/build-message.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'phase': '=phase' - }, - controller: function($scope, $element) { - $scope.getBuildMessage = function (phase) { - switch (phase) { - case 'cannot_load': - return 'Cannot load build status - Please report this error'; - - case 'starting': - case 'initializing': - return 'Starting Dockerfile build'; - - case 'waiting': - return 'Waiting for available build worker'; - - case 'unpacking': - return 'Unpacking build package'; - - case 'pulling': - return 'Pulling base image'; - - case 'building': - return 'Building image from Dockerfile'; - - case 'checking-cache': - return 'Looking up cached images'; - - case 'priming-cache': - return 'Priming cache for build'; - - case 'build-scheduled': - return 'Preparing build node'; - - case 'pushing': - return 'Pushing image built from Dockerfile'; - - case 'complete': - return 'Dockerfile build completed and pushed'; - - case 'error': - return 'Dockerfile build failed'; - - case 'internalerror': - return 'An internal system error occurred while building; the build will be retried in the next few minutes.'; - } - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('buildProgress', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/build-progress.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'build': '=build' - }, - controller: function($scope, $element) { - $scope.getPercentage = function(buildInfo) { - switch (buildInfo.phase) { - case 'pulling': - return buildInfo.status.pull_completion * 100; - break; - - case 'building': - return (buildInfo.status.current_command / buildInfo.status.total_commands) * 100; - break; - - case 'pushing': - return buildInfo.status.push_completion * 100; - break; - - case 'priming-cache': - return buildInfo.status.cache_completion * 100; - break; - - case 'complete': - return 100; - break; - - case 'initializing': - case 'checking-cache': - case 'starting': - case 'waiting': - case 'cannot_load': - case 'unpacking': - return 0; - break; - } - - return -1; - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('externalNotificationView', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/external-notification-view.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'repository': '=repository', - 'notification': '=notification', - 'notificationDeleted': '¬ificationDeleted' - }, - controller: function($scope, $element, ExternalNotificationData, ApiService) { - $scope.deleteNotification = function() { - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'uuid': $scope.notification.uuid - }; - - ApiService.deleteRepoNotification(null, params).then(function() { - $scope.notificationDeleted({'notification': $scope.notification}); - }); - }; - - $scope.testNotification = function() { - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'uuid': $scope.notification.uuid - }; - - ApiService.testRepoNotification(null, params).then(function() { - bootbox.dialog({ - "title": "Test Notification Queued", - "message": "A test version of this notification has been queued and should appear shortly", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - }); - }; - - $scope.$watch('notification', function(notification) { - if (notification) { - $scope.eventInfo = ExternalNotificationData.getEventInfo(notification.event); - $scope.methodInfo = ExternalNotificationData.getMethodInfo(notification.method); - $scope.config = notification.config; - } - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('createExternalNotificationDialog', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/create-external-notification-dialog.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'repository': '=repository', - 'counter': '=counter', - 'notificationCreated': '¬ificationCreated' - }, - controller: function($scope, $element, ExternalNotificationData, ApiService, $timeout, StringBuilderService) { - $scope.currentEvent = null; - $scope.currentMethod = null; - $scope.status = ''; - $scope.currentConfig = {}; - $scope.clearCounter = 0; - $scope.unauthorizedEmail = false; - - $scope.events = ExternalNotificationData.getSupportedEvents(); - $scope.methods = ExternalNotificationData.getSupportedMethods(); - - $scope.getPattern = function(field) { - return new RegExp(field.regex); - }; - - $scope.setEvent = function(event) { - $scope.currentEvent = event; - }; - - $scope.setMethod = function(method) { - $scope.currentConfig = {}; - $scope.currentMethod = method; - $scope.unauthorizedEmail = false; - }; - - $scope.createNotification = function() { - if (!$scope.currentConfig.email) { - $scope.performCreateNotification(); - return; - } - - $scope.status = 'checking-email'; - $scope.checkEmailAuthorization(); - }; - - $scope.checkEmailAuthorization = function() { - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'email': $scope.currentConfig.email - }; - - ApiService.checkRepoEmailAuthorized(null, params).then(function(resp) { - $scope.handleEmailCheck(resp.confirmed); - }, function(resp) { - $scope.handleEmailCheck(false); - }); - }; - - $scope.performCreateNotification = function() { - $scope.status = 'creating'; - - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name - }; - - var data = { - 'event': $scope.currentEvent.id, - 'method': $scope.currentMethod.id, - 'config': $scope.currentConfig - }; - - ApiService.createRepoNotification(data, params).then(function(resp) { - $scope.status = ''; - $scope.notificationCreated({'notification': resp}); - $('#createNotificationModal').modal('hide'); - }); - }; - - $scope.handleEmailCheck = function(isAuthorized) { - if (isAuthorized) { - $scope.performCreateNotification(); - return; - } - - if ($scope.status == 'authorizing-email-sent') { - $scope.watchEmail(); - } else { - $scope.status = 'unauthorized-email'; - } - - $scope.unauthorizedEmail = true; - }; - - $scope.sendAuthEmail = function() { - $scope.status = 'authorizing-email'; - - var params = { - 'repository': $scope.repository.namespace + '/' + $scope.repository.name, - 'email': $scope.currentConfig.email - }; - - ApiService.sendAuthorizeRepoEmail(null, params).then(function(resp) { - $scope.status = 'authorizing-email-sent'; - $scope.watchEmail(); - }); - }; - - $scope.watchEmail = function() { - // TODO: change this to SSE? - $timeout(function() { - $scope.checkEmailAuthorization(); - }, 1000); - }; - - $scope.getHelpUrl = function(field, config) { - var helpUrl = field['help_url']; - if (!helpUrl) { - return null; - } - - return StringBuilderService.buildUrl(helpUrl, config); - }; - - $scope.$watch('counter', function(counter) { - if (counter) { - $scope.clearCounter++; - $scope.status = ''; - $scope.currentEvent = null; - $scope.currentMethod = null; - $scope.unauthorizedEmail = false; - $('#createNotificationModal').modal({}); - } - }); - } - }; - return directiveDefinitionObject; -}); - - - -quayApp.directive('twitterView', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/twitter-view.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'avatarUrl': '@avatarUrl', - 'authorName': '@authorName', - 'authorUser': '@authorUser', - 'messageUrl': '@messageUrl', - 'messageDate': '@messageDate' - }, - controller: function($scope, $element) { - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('notificationsBubble', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/notifications-bubble.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - }, - controller: function($scope, UserService, NotificationService) { - $scope.notificationService = NotificationService; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('usageChart', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/usage-chart.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'current': '=current', - 'total': '=total', - 'limit': '=limit', - 'usageTitle': '@usageTitle' - }, - controller: function($scope, $element) { - $scope.limit = ""; - - var chart = null; - - var update = function() { - if ($scope.current == null || $scope.total == null) { return; } - if (!chart) { - chart = new UsageChart(); - chart.draw('usage-chart-element'); - } - - var current = $scope.current || 0; - var total = $scope.total || 0; - if (current > total) { - $scope.limit = 'over'; - } else if (current == total) { - $scope.limit = 'at'; - } else if (current >= total * 0.7) { - $scope.limit = 'near'; - } else { - $scope.limit = 'none'; - } - - chart.update($scope.current, $scope.total); - }; - - $scope.$watch('current', update); - $scope.$watch('total', update); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('notificationView', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/notification-view.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'notification': '=notification', - 'parent': '=parent' - }, - controller: function($scope, $element, $window, $location, UserService, NotificationService, ApiService) { - var stringStartsWith = function (str, prefix) { - return str.slice(0, prefix.length) == prefix; - }; - - $scope.getMessage = function(notification) { - return NotificationService.getMessage(notification); - }; - - $scope.getAvatar = function(orgname) { - var organization = UserService.getOrganization(orgname); - return organization['avatar'] || ''; - }; - - $scope.parseDate = function(dateString) { - return Date.parse(dateString); - }; - - $scope.showNotification = function() { - var url = NotificationService.getPage($scope.notification); - if (url) { - if (stringStartsWith(url, 'http://') || stringStartsWith(url, 'https://')) { - $window.location.href = url; - } else { - var parts = url.split('?') - $location.path(parts[0]); - - if (parts.length > 1) { - $location.search(parts[1]); - } - - $scope.parent.$hide(); - } - } - }; - - $scope.dismissNotification = function(notification) { - NotificationService.dismissNotification(notification); - }; - - $scope.canDismiss = function(notification) { - return NotificationService.canDismiss(notification); - }; - - $scope.getClass = function(notification) { - return NotificationService.getClass(notification); - }; - - $scope.getActions = function(notification) { - return NotificationService.getActions(notification); - }; - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('dockerfileBuildDialog', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/dockerfile-build-dialog.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'repository': '=repository', - 'showNow': '=showNow', - 'buildStarted': '&buildStarted' - }, - controller: function($scope, $element) { - $scope.building = false; - $scope.uploading = false; - $scope.startCounter = 0; - - $scope.handleBuildStarted = function(build) { - $('#dockerfilebuildModal').modal('hide'); - if ($scope.buildStarted) { - $scope.buildStarted({'build': build}); - } - }; - - $scope.handleBuildFailed = function(message) { - $scope.errorMessage = message; - }; - - $scope.startBuild = function() { - $scope.errorMessage = null; - $scope.startCounter++; - }; - - $scope.$watch('showNow', function(sn) { - if (sn && $scope.repository) { - $('#dockerfilebuildModal').modal({}); - } - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('dockerfileBuildForm', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/dockerfile-build-form.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'repository': '=repository', - 'startNow': '=startNow', - 'isReady': '=isReady', - 'uploadFailed': '&uploadFailed', - 'uploadStarted': '&uploadStarted', - 'buildStarted': '&buildStarted', - 'buildFailed': '&buildFailed', - 'missingFile': '&missingFile', - 'uploading': '=uploading', - 'building': '=building' - }, - controller: function($scope, $element, ApiService) { - $scope.internal = {'hasDockerfile': false}; - $scope.pull_entity = null; - $scope.is_public = true; - - var handleBuildFailed = function(message) { - message = message || 'Dockerfile build failed to start'; - - var result = false; - if ($scope.buildFailed) { - result = $scope.buildFailed({'message': message}); - } - - if (!result) { - bootbox.dialog({ - "message": message, - "title": "Cannot start Dockerfile build", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - } - }; - - var handleUploadFailed = function(message) { - message = message || 'Error with file upload'; - - var result = false; - if ($scope.uploadFailed) { - result = $scope.uploadFailed({'message': message}); - } - - if (!result) { - bootbox.dialog({ - "message": message, - "title": "Cannot upload file for Dockerfile build", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - } - }; - - var handleMissingFile = function() { - var result = false; - if ($scope.missingFile) { - result = $scope.missingFile({}); - } - - if (!result) { - bootbox.dialog({ - "message": 'A Dockerfile or an archive containing a Dockerfile is required', - "title": "Missing Dockerfile", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - } - }; - - var startBuild = function(fileId) { - $scope.building = true; - - var repo = $scope.repository; - var data = { - 'file_id': fileId - }; - - if (!$scope.is_public && $scope.pull_entity) { - data['pull_robot'] = $scope.pull_entity['name']; - } - - var params = { - 'repository': repo.namespace + '/' + repo.name, - }; - - ApiService.requestRepoBuild(data, params).then(function(resp) { - $scope.building = false; - $scope.uploading = false; - - if ($scope.buildStarted) { - $scope.buildStarted({'build': resp}); - } - }, function(resp) { - $scope.building = false; - $scope.uploading = false; - - handleBuildFailed(resp.message); - }); - }; - - var conductUpload = function(file, url, fileId, mimeType) { - if ($scope.uploadStarted) { - $scope.uploadStarted({}); - } - - var request = new XMLHttpRequest(); - request.open('PUT', url, true); - request.setRequestHeader('Content-Type', mimeType); - request.onprogress = function(e) { - $scope.$apply(function() { - var percentLoaded; - if (e.lengthComputable) { - $scope.upload_progress = (e.loaded / e.total) * 100; - } - }); - }; - request.onerror = function() { - $scope.$apply(function() { - handleUploadFailed(); - }); - }; - request.onreadystatechange = function() { - var state = request.readyState; - if (state == 4) { - $scope.$apply(function() { - startBuild(fileId); - $scope.uploading = false; - }); - return; - } - }; - request.send(file); - }; - - var startFileUpload = function(repo) { - $scope.uploading = true; - $scope.uploading_progress = 0; - - var uploader = $('#file-drop')[0]; - if (uploader.files.length == 0) { - handleMissingFile(); - $scope.uploading = false; - return; - } - - var file = uploader.files[0]; - $scope.upload_file = file.name; - - var mimeType = file.type || 'application/octet-stream'; - var data = { - 'mimeType': mimeType - }; - - var getUploadUrl = ApiService.getFiledropUrl(data).then(function(resp) { - conductUpload(file, resp.url, resp.file_id, mimeType); - }, function() { - handleUploadFailed('Could not retrieve upload URL'); - }); - }; - - var checkIsReady = function() { - $scope.isReady = $scope.internal.hasDockerfile && ($scope.is_public || $scope.pull_entity); - }; - - $scope.$watch('pull_entity', checkIsReady); - $scope.$watch('is_public', checkIsReady); - $scope.$watch('internal.hasDockerfile', checkIsReady); - - $scope.$watch('startNow', function() { - if ($scope.startNow && $scope.repository && !$scope.uploading && !$scope.building) { - startFileUpload(); - } - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('locationView', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/location-view.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'location': '=location' - }, - controller: function($rootScope, $scope, $element, $http, PingService) { - var LOCATIONS = { - 'local_us': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States' }, - 'local_eu': { 'country': 'EU', 'data': 'quay-registry-eu.s3-eu-west-1.amazonaws.com', 'title': 'Europe' }, - - 's3_us_east_1': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States (East)' }, - 's3_us_west_1': { 'country': 'US', 'data': 'quay-registry-cali.s3.amazonaws.com', 'title': 'United States (West)' }, - - 's3_eu_west_1': { 'country': 'EU', 'data': 'quay-registry-eu.s3-eu-west-1.amazonaws.com', 'title': 'Europe' }, - - 's3_ap_southeast_1': { 'country': 'SG', 'data': 'quay-registry-singapore.s3-ap-southeast-1.amazonaws.com', 'title': 'Singapore' }, - 's3_ap_southeast_2': { 'country': 'AU', 'data': 'quay-registry-sydney.s3-ap-southeast-2.amazonaws.com', 'title': 'Australia' }, - -// 's3_ap_northeast-1': { 'country': 'JP', 'data': 's3-ap-northeast-1.amazonaws.com', 'title': 'Japan' }, -// 's3_sa_east1': { 'country': 'BR', 'data': 's3-east-1.amazonaws.com', 'title': 'Sao Paulo' } - }; - - $scope.locationPing = null; - $scope.locationPingClass = null; - - $scope.getLocationTooltip = function(location, ping) { - var tip = $scope.getLocationTitle(location) + '
'; - if (ping == null) { - tip += '(Loading)'; - } else if (ping < 0) { - tip += '
Note: Could not contact server'; - } else { - tip += 'Estimated Ping: ' + (ping ? ping + 'ms' : '(Loading)'); - } - return tip; - }; - - $scope.getLocationTitle = function(location) { - if (!LOCATIONS[location]) { - return '(Unknown)'; - } - return 'Image data is located in ' + LOCATIONS[location]['title']; - }; - - $scope.getLocationImage = function(location) { - if (!LOCATIONS[location]) { - return 'unknown.png'; - } - return LOCATIONS[location]['country'] + '.png'; - }; - - $scope.getLocationPing = function(location) { - var url = 'https://' + LOCATIONS[location]['data'] + '/okay.txt'; - PingService.pingUrl($scope, url, function(ping, success, count) { - if (count == 3 || !success) { - $scope.locationPing = success ? ping : -1; - } - }); - }; - - $scope.$watch('location', function(location) { - if (!location) { return; } - $scope.getLocationPing(location); - }); - - $scope.$watch('locationPing', function(locationPing) { - if (locationPing == null) { - $scope.locationPingClass = null; - return; - } - - if (locationPing < 0) { - $scope.locationPingClass = 'error'; - return; - } - - if (locationPing < 100) { - $scope.locationPingClass = 'good'; - return; - } - - if (locationPing < 250) { - $scope.locationPingClass = 'fair'; - return; - } - - if (locationPing < 500) { - $scope.locationPingClass = 'barely'; - return; - } - - $scope.locationPingClass = 'poor'; - }); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('psUsageGraph', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/ps-usage-graph.html', - replace: false, - transclude: false, - restrict: 'C', - scope: { - 'isEnabled': '=isEnabled' - }, - controller: function($scope, $element) { - $scope.counter = -1; - $scope.data = null; - - var source = null; - - var connect = function() { - if (source) { return; } - source = new EventSource('/realtime/ps'); - source.onmessage = function(e) { - $scope.$apply(function() { - $scope.counter++; - $scope.data = JSON.parse(e.data); - }); - }; - }; - - var disconnect = function() { - if (!source) { return; } - source.close(); - source = null; - }; - - $scope.$watch('isEnabled', function(value) { - if (value) { - connect(); - } else { - disconnect(); - } - }); - - $scope.$on("$destroy", disconnect); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('avatar', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/avatar.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'hash': '=hash', - 'email': '=email', - 'name': '=name', - 'size': '=size' - }, - controller: function($scope, $element, AvatarService) { - $scope.AvatarService = AvatarService; - - var refreshHash = function() { - if (!$scope.name && !$scope.email) { return; } - $scope._hash = AvatarService.computeHash($scope.email, $scope.name); - }; - - $scope.$watch('hash', function(hash) { - $scope._hash = hash; - }); - - $scope.$watch('name', refreshHash); - $scope.$watch('email', refreshHash); - } - }; - return directiveDefinitionObject; -}); - - -quayApp.directive('tagSpecificImagesView', function () { - var directiveDefinitionObject = { - priority: 0, - templateUrl: '/static/directives/tag-specific-images-view.html', - replace: false, - transclude: true, - restrict: 'C', - scope: { - 'repository': '=repository', - 'tag': '=tag', - 'images': '=images', - 'imageCutoff': '=imageCutoff' - }, - controller: function($scope, $element) { - $scope.getFirstTextLine = getFirstTextLine; - - $scope.hasImages = false; - $scope.tagSpecificImages = []; - - $scope.getImageListingClasses = function(image) { - var classes = ''; - if (image.ancestors.length > 1) { - classes += 'child '; - } - - var currentTag = $scope.repository.tags[$scope.tag]; - if (image.id == currentTag.image_id) { - classes += 'tag-image '; - } - - return classes; - }; - - var forAllTagImages = function(tag, callback, opt_cutoff) { - if (!tag) { return; } - - if (!$scope.imageByDockerId) { - $scope.imageByDockerId = []; - for (var i = 0; i < $scope.images.length; ++i) { - var currentImage = $scope.images[i]; - $scope.imageByDockerId[currentImage.id] = currentImage; - } - } - - var tag_image = $scope.imageByDockerId[tag.image_id]; - if (!tag_image) { - return; - } - - callback(tag_image); - - var ancestors = tag_image.ancestors.split('/').reverse(); - for (var i = 0; i < ancestors.length; ++i) { - var image = $scope.imageByDockerId[ancestors[i]]; - if (image) { - if (image == opt_cutoff) { - return; - } - - callback(image); - } - } - }; - - var refresh = function() { - if (!$scope.repository || !$scope.tag || !$scope.images) { - $scope.tagSpecificImages = []; - return; - } - - var tag = $scope.repository.tags[$scope.tag]; - if (!tag) { - $scope.tagSpecificImages = []; - return; - } - - var getIdsForTag = function(currentTag) { - var ids = {}; - forAllTagImages(currentTag, function(image) { - ids[image.id] = true; - }, $scope.imageCutoff); - return ids; - }; - - // Remove any IDs that match other tags. - var toDelete = getIdsForTag(tag); - for (var currentTagName in $scope.repository.tags) { - var currentTag = $scope.repository.tags[currentTagName]; - if (currentTag != tag) { - for (var id in getIdsForTag(currentTag)) { - delete toDelete[id]; - } - } - } - - // Return the matching list of images. - var images = []; - for (var i = 0; i < $scope.images.length; ++i) { - var image = $scope.images[i]; - if (toDelete[image.id]) { - images.push(image); - } - } - - images.sort(function(a, b) { - var result = new Date(b.created) - new Date(a.created); - if (result != 0) { - return result; - } - - return b.sort_index - a.sort_index; - }); - - $scope.tagSpecificImages = images; - }; - - $scope.$watch('repository', refresh); - $scope.$watch('tag', refresh); - $scope.$watch('images', refresh); - } - }; - return directiveDefinitionObject; -}); - -quayApp.directive('fallbackSrc', function () { - return { - restrict: 'A', - link: function postLink(scope, element, attributes) { - element.bind('error', function() { - angular.element(this).attr("src", attributes.fallbackSrc); - }); - } - }; -}); - - -// Note: ngBlur is not yet in Angular stable, so we add it manaully here. -quayApp.directive('ngBlur', function() { - return function( scope, elem, attrs ) { - elem.bind('blur', function() { - scope.$apply(attrs.ngBlur); - }); - }; -}); - - -quayApp.directive("filePresent", [function () { - return { - restrict: 'A', - scope: { - 'filePresent': "=" - }, - link: function (scope, element, attributes) { - element.bind("change", function (changeEvent) { - scope.$apply(function() { - scope.filePresent = changeEvent.target.files.length > 0; - }); - }); - } - } -}]); - -quayApp.directive('ngVisible', function () { - return function (scope, element, attr) { - scope.$watch(attr.ngVisible, function (visible) { - element.css('visibility', visible ? 'visible' : 'hidden'); - }); - }; -}); - -quayApp.config( [ - '$compileProvider', - function( $compileProvider ) - { - $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|ftp|mailto|tel|irc):/); - } -]); - -quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanService', '$http', '$timeout', 'CookieService', 'Features', '$anchorScroll', - function($location, $rootScope, Restangular, UserService, PlanService, $http, $timeout, CookieService, Features, $anchorScroll) { + var title = window.__config['REGISTRY_TITLE'] || 'Quay.io'; // Handle session security. Restangular.setDefaultRequestParams(['post', 'put', 'remove', 'delete'], {'_csrf_token': window.__token || ''}); @@ -6931,7 +261,7 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi return; } - clickElement(this); + UtilService.clickElement(this); }); }, opt_timeout); }; @@ -6940,7 +270,7 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi $timeout(function() { $('a[data-toggle="tab"]').each(function(index) { if (index == 0) { - clickElement(this); + UtilService.clickElement(this); } }); }); @@ -6973,6 +303,8 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi if (current.$$route.title) { $rootScope.title = current.$$route.title; + } else { + $rootScope.title = title; } if (current.$$route.pageClass) { diff --git a/static/js/controllers.js b/static/js/controllers.js deleted file mode 100644 index a242bf8cd..000000000 --- a/static/js/controllers.js +++ /dev/null @@ -1,2532 +0,0 @@ -function SignInCtrl($scope, $location) { - $scope.redirectUrl = '/'; -} - -function GuideCtrl() { -} - -function SecurityCtrl($scope) { -} - -function ContactCtrl($scope, Config) { - $scope.Config = Config; - $scope.colsize = Math.floor(12 / Config.CONTACT_INFO.length); - - $scope.getKind = function(contactInfo) { - var colon = contactInfo.indexOf(':'); - var scheme = contactInfo.substr(0, colon); - if (scheme == 'https' || scheme == 'http') { - if (contactInfo.indexOf('//twitter.com/') > 0) { - return 'twitter'; - } - - return 'url'; - } - - return scheme; - }; - - $scope.getTitle = function(contactInfo) { - switch ($scope.getKind(contactInfo)) { - case 'url': - return contactInfo; - - case 'twitter': - var parts = contactInfo.split('/'); - return '@' + parts[parts.length - 1]; - - case 'tel': - return contactInfo.substr('tel:'.length); - - case 'irc': - // irc://chat.freenode.net:6665/quayio - var parts = contactInfo.substr('irc://'.length).split('/'); - var server = parts[0]; - if (server.indexOf('freenode') > 0) { - server = 'Freenode'; - } - return server + ': #' + parts[parts.length - 1]; - - case 'mailto': - return contactInfo.substr('mailto:'.length); - } - } -} - -function PlansCtrl($scope, $location, UserService, PlanService, $routeParams) { - // Monitor any user changes and place the current user into the scope. - UserService.updateUserIn($scope); - - $scope.signedIn = function() { - $('#signinModal').modal('hide'); - PlanService.handleNotedPlan(); - }; - - $scope.buyNow = function(plan) { - PlanService.notePlan(plan); - if ($scope.user && !$scope.user.anonymous) { - PlanService.handleNotedPlan(); - } else { - $('#signinModal').modal({}); - } - }; - - // Load the list of plans. - PlanService.getPlans(function(plans) { - $scope.plans = plans; - - if ($scope && $routeParams['trial-plan']) { - $scope.buyNow($routeParams['trial-plan']); - } - }, /* include the personal plan */ true); -} - -function TutorialCtrl($scope, AngularTour, AngularTourSignals, UserService, Config) { - // Default to showing sudo on all commands if on linux. - var showSudo = navigator.appVersion.indexOf("Linux") != -1; - - $scope.tour = { - 'title': Config.REGISTRY_TITLE_SHORT + ' Tutorial', - 'initialScope': { - 'showSudo': showSudo, - 'domainName': Config.getDomain() - }, - 'steps': [ - { - 'title': 'Welcome to the ' + Config.REGISTRY_TITLE_SHORT + ' tutorial!', - 'templateUrl': '/static/tutorial/welcome.html' - }, - { - 'title': 'Sign in to get started', - 'templateUrl': '/static/tutorial/signup.html', - 'signal': function($tourScope) { - var user = UserService.currentUser(); - $tourScope.username = user.username; - $tourScope.email = user.email; - $tourScope.inOrganization = user.organizations && user.organizations.length > 0; - return !user.anonymous; - } - }, - { - 'title': 'Step 1: Login to ' + Config.REGISTRY_TITLE, - 'templateUrl': '/static/tutorial/docker-login.html', - 'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli', - function(message) { - return message['data']['action'] == 'login'; - }), - 'waitMessage': "Waiting for docker login", - 'skipTitle': "I'm already logged in", - 'mixpanelEvent': 'tutorial_start' - }, - { - 'title': 'Step 2: Create a new container', - 'templateUrl': '/static/tutorial/create-container.html' - }, - { - 'title': 'Step 3: Create a new image', - 'templateUrl': '/static/tutorial/create-image.html' - }, - { - 'title': 'Step 4: Push the image to ' + Config.REGISTRY_TITLE, - 'templateUrl': '/static/tutorial/push-image.html', - 'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli', - function(message, tourScope) { - var pushing = message['data']['action'] == 'push_repo'; - if (pushing) { - tourScope.repoName = message['data']['repository']; - } - return pushing; - }), - 'waitMessage': "Waiting for repository push to begin", - 'mixpanelEvent': 'tutorial_wait_for_push' - }, - { - 'title': 'Push in progress', - 'templateUrl': '/static/tutorial/pushing.html', - 'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli', - function(message, tourScope) { - return message['data']['action'] == 'pushed_repo'; - }), - 'waitMessage': "Waiting for repository push to complete" - }, - { - 'title': 'Step 5: View the repository on ' + Config.REGISTRY_TITLE, - 'templateUrl': '/static/tutorial/view-repo.html', - 'signal': AngularTourSignals.matchesLocation('/repository/'), - 'overlayable': true, - 'mixpanelEvent': 'tutorial_push_complete' - }, - { - 'templateUrl': '/static/tutorial/view-repo.html', - 'signal': AngularTourSignals.matchesLocation('/repository/'), - 'overlayable': true - }, - { - 'templateUrl': '/static/tutorial/waiting-repo-list.html', - 'signal': AngularTourSignals.elementAvaliable('*[data-repo="{{username}}/{{repoName}}"]'), - 'overlayable': true - }, - { - 'templateUrl': '/static/tutorial/repo-list.html', - 'signal': AngularTourSignals.matchesLocation('/repository/{{username}}/{{repoName}}'), - 'element': '*[data-repo="{{username}}/{{repoName}}"]', - 'overlayable': true - }, - { - 'title': 'Repository View', - 'content': 'This is the repository view page. It displays all the primary information about your repository.', - 'overlayable': true, - 'mixpanelEvent': 'tutorial_view_repo' - }, - { - 'title': 'Image History', - 'content': 'The tree displays the full history of your repository, including all its tag. ' + - 'You can click on a tag or image to see its information.', - 'element': '#image-history-container', - 'overlayable': true - }, - { - 'title': 'Tag/Image Information', - 'content': 'This panel displays information about the currently selected tag or image', - 'element': '#side-panel', - 'overlayable': true - }, - { - 'title': 'Select tag or image', - 'content': 'You can select a tag or image by clicking on this dropdown', - 'element': '#side-panel-dropdown', - 'overlayable': true - }, - { - 'content': 'To view the admin settings for the repository, click on the gear', - 'element': '#admin-cog', - 'signal': AngularTourSignals.matchesLocation('/repository/{{username}}/{{repoName}}/admin'), - 'overlayable': true - }, - { - 'title': 'Repository Admin', - 'content': "The repository admin panel allows for modification of a repository's permissions, notifications, visibility and other settings", - 'overlayable': true, - 'mixpanelEvent': 'tutorial_view_admin' - }, - { - 'title': 'Permissions', - 'templateUrl': '/static/tutorial/permissions.html', - 'overlayable': true, - 'element': '#permissions' - }, - { - 'title': 'Adding a permission', - 'content': 'To add an additional permission, enter a username or robot account name into the autocomplete ' + - 'or hit the dropdown arrow to manage robot accounts', - 'overlayable': true, - 'element': '#add-entity-permission' - }, - { - 'templateUrl': '/static/tutorial/done.html', - 'overlayable': true, - 'mixpanelEvent': 'tutorial_complete' - } - ] - }; -} - -function LandingCtrl($scope, UserService, ApiService, Features, Config) { - $scope.namespace = null; - $scope.currentScreenshot = 'repo-view'; - - $scope.$watch('namespace', function(namespace) { - loadMyRepos(namespace); - }); - - UserService.updateUserIn($scope, function() { - loadMyRepos($scope.namespace); - }); - - $scope.changeScreenshot = function(screenshot) { - $scope.currentScreenshot = screenshot; - }; - - $scope.canCreateRepo = function(namespace) { - if (!$scope.user) { return false; } - - if (namespace == $scope.user.username) { - return true; - } - - if ($scope.user.organizations) { - for (var i = 0; i < $scope.user.organizations.length; ++i) { - var org = $scope.user.organizations[i]; - if (org.name == namespace) { - return org.can_create_repo; - } - } - } - - return false; - }; - - var loadMyRepos = function(namespace) { - if (!$scope.user || $scope.user.anonymous || !namespace) { - return; - } - - var options = {'limit': 4, 'public': false, 'sort': true, 'namespace': namespace }; - $scope.my_repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) { - return resp.repositories; - }); - }; - - $scope.chromify = function() { - browserchrome.update(); - - var jcarousel = $('.jcarousel'); - - jcarousel - .on('jcarousel:reload jcarousel:create', function () { - var width = jcarousel.innerWidth(); - jcarousel.jcarousel('items').css('width', width + 'px'); - }) - .jcarousel({ - wrap: 'circular' - }); - - $('.jcarousel-control-prev') - .on('jcarouselcontrol:active', function() { - $(this).removeClass('inactive'); - }) - .on('jcarouselcontrol:inactive', function() { - $(this).addClass('inactive'); - }) - .jcarouselControl({ - target: '-=1' - }); - - $('.jcarousel-control-next') - .on('jcarouselcontrol:active', function() { - $(this).removeClass('inactive'); - }) - .on('jcarouselcontrol:inactive', function() { - $(this).addClass('inactive'); - }) - .jcarouselControl({ - target: '+=1' - }); - - $('.jcarousel-pagination') - .on('jcarouselpagination:active', 'a', function() { - $(this).addClass('active'); - }) - .on('jcarouselpagination:inactive', 'a', function() { - $(this).removeClass('active'); - }) - .jcarouselPagination({ - 'item': function(page, carouselItems) { - return ''; - } - }); - }; - - $scope.getEnterpriseLogo = function() { - if (!Config.ENTERPRISE_LOGO_URL) { - return '/static/img/quay-logo.png'; - } - - return Config.ENTERPRISE_LOGO_URL; - }; -} - -function StarCtrl($scope) { - $scope.test = "hello"; -} - -function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiService, $routeParams, $rootScope, $location, $timeout, Config) { - $scope.Config = Config; - - var namespace = $routeParams.namespace; - var name = $routeParams.name; - - $scope.pullCommands = []; - $scope.currentPullCommand = null; - - $rootScope.title = 'Loading...'; - - // Watch for the destruction of the scope. - $scope.$on('$destroy', function() { - if ($scope.tree) { - $scope.tree.dispose(); - } - }); - - // Watch for changes to the repository. - $scope.$watch('repo', function() { - if ($scope.tree) { - $timeout(function() { - $scope.tree.notifyResized(); - }); - } - }); - - // Watch for changes to the tag parameter. - $scope.$on('$routeUpdate', function(){ - if ($location.search().tag) { - $scope.setTag($location.search().tag, false); - } else if ($location.search().image) { - $scope.setImage($location.search().image, false); - } else { - $scope.setTag($location.search().tag, false); - } - }); - - // Start scope methods ////////////////////////////////////////// - - $scope.buildDialogShowCounter = 0; - $scope.getFormattedCommand = ImageMetadataService.getFormattedCommand; - - $scope.setCurrentPullCommand = function(pullCommand) { - $scope.currentPullCommand = pullCommand; - }; - - $scope.updatePullCommand = function() { - $scope.pullCommands = []; - - if ($scope.currentTag) { - $scope.pullCommands.push({ - 'title': 'docker pull (Tag ' + $scope.currentTag.name + ')', - 'shortTitle': 'Pull Tag', - 'icon': 'fa-tag', - 'command': 'docker pull ' + Config.getDomain() + '/' + namespace + '/' + name + ':' + $scope.currentTag.name - }); - } - - $scope.pullCommands.push({ - 'title': 'docker pull (Full Repository)', - 'shortTitle': 'Pull Repo', - 'icon': 'fa-code-fork', - 'command': 'docker pull ' + Config.getDomain() + '/' + namespace + '/' + name - }); - - if ($scope.currentTag) { - var squash = 'curl -L -f ' + Config.getHost('ACCOUNTNAME:PASSWORDORTOKEN'); - squash += '/c1/squash/' + namespace + '/' + name + '/' + $scope.currentTag.name; - squash += ' | docker load'; - - $scope.pullCommands.push({ - 'title': 'Squashed image (Tag ' + $scope.currentTag.name + ')', - 'shortTitle': 'Squashed', - 'icon': 'fa-file-archive-o', - 'command': squash, - 'experimental': true - }); - } - - $scope.currentPullCommand = $scope.pullCommands[0]; - }; - - $scope.showNewBuildDialog = function() { - $scope.buildDialogShowCounter++; - }; - - $scope.handleBuildStarted = function(build) { - getBuildInfo($scope.repo); - startBuildInfoTimer($scope.repo); - }; - - $scope.showBuild = function(buildInfo) { - $location.path('/repository/' + namespace + '/' + name + '/build'); - $location.search('current', buildInfo.id); - }; - - $scope.isPushing = function(images) { - if (!images) { return false; } - - var cached = images.__isPushing; - if (cached !== undefined) { - return cached; - } - - return images.__isPushing = $scope.isPushingInternal(images); - }; - - $scope.isPushingInternal = function(images) { - if (!images) { return false; } - - for (var i = 0; i < images.length; ++i) { - if (images[i].uploading) { return true; } - } - - return false; - }; - - $scope.getTooltipCommand = function(image) { - var sanitized = ImageMetadataService.getEscapedFormattedCommand(image); - return '' + sanitized + ''; - }; - - $scope.updateForDescription = function(content) { - $scope.repo.description = content; - $scope.repo.put(); - }; - - $scope.parseDate = function(dateString) { - return Date.parse(dateString); - }; - - $scope.getTimeSince = function(createdTime) { - return moment($scope.parseDate(createdTime)).fromNow(); - }; - - $scope.loadImageChanges = function(image) { - if (!image) { return; } - - var params = {'repository': namespace + '/' + name, 'image_id': image.id}; - $scope.currentImageChangeResource = ApiService.getImageChangesAsResource(params).get(function(ci) { - $scope.currentImageChanges = ci; - }); - }; - - $scope.getMoreCount = function(changes) { - if (!changes) { return 0; } - var addedDisplayed = Math.min(2, changes.added.length); - var removedDisplayed = Math.min(2, changes.removed.length); - var changedDisplayed = Math.min(2, changes.changed.length); - - return (changes.added.length + changes.removed.length + changes.changed.length) - - addedDisplayed - removedDisplayed - changedDisplayed; - }; - - $scope.showAddTag = function(image) { - $scope.toTagImage = image; - $('#addTagModal').modal('show'); - setTimeout(function() { - $('#tagName').focus(); - }, 500); - }; - - $scope.isOwnedTag = function(image, tagName) { - if (!image || !tagName) { return false; } - return image.tags.indexOf(tagName) >= 0; - }; - - $scope.isAnotherImageTag = function(image, tagName) { - if (!image || !tagName) { return false; } - return image.tags.indexOf(tagName) < 0 && $scope.repo.tags[tagName]; - }; - - $scope.askDeleteTag = function(tagName) { - if (!$scope.repo.can_admin) { return; } - - $scope.tagToDelete = tagName; - $('#confirmdeleteTagModal').modal('show'); - }; - - $scope.findImageForTag = function(tag) { - return tag && $scope.imageByDockerId && $scope.imageByDockerId[tag.image_id]; - }; - - $scope.createOrMoveTag = function(image, tagName, opt_invalid) { - if (opt_invalid) { return; } - - $scope.creatingTag = true; - - var params = { - 'repository': $scope.repo.namespace + '/' + $scope.repo.name, - 'tag': tagName - }; - - var data = { - 'image': image.id - }; - - var errorHandler = ApiService.errorDisplay('Cannot create or move tag', function(resp) { - $('#addTagModal').modal('hide'); - }); - - ApiService.changeTagImage(data, params).then(function(resp) { - $scope.creatingTag = false; - loadViewInfo(); - $('#addTagModal').modal('hide'); - }, errorHandler); - }; - - $scope.deleteTag = function(tagName) { - if (!$scope.repo.can_admin) { return; } - - var params = { - 'repository': namespace + '/' + name, - 'tag': tagName - }; - - var errorHandler = ApiService.errorDisplay('Cannot delete tag', function() { - $('#confirmdeleteTagModal').modal('hide'); - $scope.deletingTag = false; - }); - - $scope.deletingTag = true; - - ApiService.deleteFullTag(null, params).then(function() { - loadViewInfo(); - $('#confirmdeleteTagModal').modal('hide'); - $scope.deletingTag = false; - }, errorHandler); - }; - - $scope.getImagesForTagBySize = function(tag) { - var images = []; - forAllTagImages(tag, function(image) { - images.push(image); - }); - - images.sort(function(a, b) { - return b.size - a.size; - }); - - return images; - }; - - $scope.getTotalSize = function(tag) { - var size = 0; - forAllTagImages(tag, function(image) { - size += image.size; - }); - return size; - }; - - $scope.setImage = function(imageId, opt_updateURL) { - if (!$scope.images) { return; } - - var image = null; - for (var i = 0; i < $scope.images.length; ++i) { - var currentImage = $scope.images[i]; - if (currentImage.id == imageId || currentImage.id.substr(0, 12) == imageId) { - image = currentImage; - break; - } - } - - if (!image) { return; } - - $scope.currentTag = null; - $scope.currentImage = image; - $scope.loadImageChanges(image); - if ($scope.tree) { - $scope.tree.setImage(image.id); - } - - if (opt_updateURL) { - $location.search('tag', null); - $location.search('image', imageId.substr(0, 12)); - } - - $scope.updatePullCommand(); - }; - - $scope.setTag = function(tagName, opt_updateURL) { - var repo = $scope.repo; - if (!repo) { return; } - - var proposedTag = repo.tags[tagName]; - if (!proposedTag) { - // We must find a good default. - for (tagName in repo.tags) { - if (!proposedTag || tagName == 'latest') { - proposedTag = repo.tags[tagName]; - } - } - } - - if (proposedTag) { - $scope.currentTag = proposedTag; - $scope.currentImage = null; - - if ($scope.tree) { - $scope.tree.setTag(proposedTag.name); - } - - if (opt_updateURL) { - $location.search('image', null); - $location.search('tag', proposedTag.name); - } - } - - if ($scope.currentTag && !repo.tags[$scope.currentTag.name]) { - $scope.currentTag = null; - $scope.currentImage = null; - } - - $scope.updatePullCommand(); - }; - - $scope.getFirstTextLine = getFirstTextLine; - - $scope.getTagCount = function(repo) { - if (!repo) { return 0; } - var count = 0; - for (var tag in repo.tags) { - ++count; - } - return count; - }; - - $scope.hideTagMenu = function(tagName, clientX, clientY) { - $scope.currentMenuTag = null; - - var tagMenu = $("#tagContextMenu"); - tagMenu.hide(); - }; - - $scope.showTagMenu = function(tagName, clientX, clientY) { - if (!$scope.repo.can_admin) { return; } - - $scope.currentMenuTag = tagName; - - var tagMenu = $("#tagContextMenu"); - tagMenu.css({ - display: "block", - left: clientX, - top: clientY - }); - - tagMenu.on("blur", function() { - setTimeout(function() { - tagMenu.hide(); - }, 100); // Needed to allow clicking on menu items. - }); - - tagMenu.on("click", "a", function() { - setTimeout(function() { - tagMenu.hide(); - }, 100); // Needed to allow clicking on menu items. - }); - - tagMenu[0].focus(); - }; - - var getDefaultTag = function() { - if ($scope.repo === undefined) { - return undefined; - } else if ($scope.repo.tags.hasOwnProperty('latest')) { - return $scope.repo.tags['latest']; - } else { - for (key in $scope.repo.tags) { - return $scope.repo.tags[key]; - } - } - }; - - var forAllTagImages = function(tag, callback) { - if (!tag || !$scope.imageByDockerId) { return; } - - var tag_image = $scope.imageByDockerId[tag.image_id]; - if (!tag_image) { return; } - - // Callback the tag's image itself. - callback(tag_image); - - // Callback any parent images. - if (!tag_image.ancestors) { return; } - var ancestors = tag_image.ancestors.split('/'); - for (var i = 0; i < ancestors.length; ++i) { - var image = $scope.imageByDockerId[ancestors[i]]; - if (image) { - callback(image); - } - } - }; - - var fetchRepository = function() { - var params = {'repository': namespace + '/' + name}; - $rootScope.title = 'Loading Repository...'; - $scope.repository = ApiService.getRepoAsResource(params).get(function(repo) { - // Set the repository object. - $scope.repo = repo; - - // Set the default tag. - $scope.setTag($routeParams.tag); - - // Set the title of the page. - var qualifiedRepoName = namespace + '/' + name; - $rootScope.title = qualifiedRepoName; - var kind = repo.is_public ? 'public' : 'private'; - $rootScope.description = jQuery(getFirstTextLine(repo.description)).text() || - 'Visualization of images and tags for ' + kind + ' Docker repository: ' + qualifiedRepoName; - - // Load the builds for this repository. If none are active it will cancel the poll. - startBuildInfoTimer(repo); - }); - }; - - var startBuildInfoTimer = function(repo) { - if ($scope.interval) { return; } - - getBuildInfo(repo); - $scope.interval = setInterval(function() { - $scope.$apply(function() { getBuildInfo(repo); }); - }, 5000); - - $scope.$on("$destroy", function() { - cancelBuildInfoTimer(); - }); - }; - - var cancelBuildInfoTimer = function() { - if ($scope.interval) { - clearInterval($scope.interval); - } - }; - - var getBuildInfo = function(repo) { - var params = { - 'repository': repo.namespace + '/' + repo.name - }; - - ApiService.getRepoBuilds(null, params, true).then(function(resp) { - // Build a filtered list of the builds that are currently running. - var runningBuilds = []; - for (var i = 0; i < resp.builds.length; ++i) { - var build = resp.builds[i]; - if (build['phase'] != 'complete' && build['phase'] != 'error') { - runningBuilds.push(build); - } - } - - var existingBuilds = $scope.runningBuilds || []; - $scope.runningBuilds = runningBuilds; - $scope.buildHistory = resp.builds; - - if (!runningBuilds.length) { - // Cancel the build timer. - cancelBuildInfoTimer(); - - // Mark the repo as no longer building. - $scope.repo.is_building = false; - - // Reload the repo information if all of the builds recently finished. - if (existingBuilds.length > 0) { - loadViewInfo(); - } - } - }); - }; - - var listImages = function() { - var params = {'repository': namespace + '/' + name}; - $scope.imageHistory = ApiService.listRepositoryImagesAsResource(params).get(function(resp) { - $scope.images = resp.images; - $scope.specificImages = []; - - // Build various images for quick lookup of images. - $scope.imageByDockerId = {}; - for (var i = 0; i < $scope.images.length; ++i) { - var currentImage = $scope.images[i]; - $scope.imageByDockerId[currentImage.id] = currentImage; - } - - // Dispose of any existing tree. - if ($scope.tree) { - $scope.tree.dispose(); - } - - // Create the new tree. - var tree = new ImageHistoryTree(namespace, name, resp.images, - getFirstTextLine, $scope.getTimeSince, ImageMetadataService.getEscapedFormattedCommand); - - $scope.tree = tree.draw('image-history-container'); - if ($scope.tree) { - // If we already have a tag, use it - if ($scope.currentTag) { - $scope.tree.setTag($scope.currentTag.name); - } - - // Listen for changes to the selected tag and image in the tree. - $($scope.tree).bind('tagChanged', function(e) { - $scope.$apply(function() { $scope.setTag(e.tag, true); }); - }); - - $($scope.tree).bind('imageChanged', function(e) { - $scope.$apply(function() { $scope.setImage(e.image.id, true); }); - }); - - $($scope.tree).bind('showTagMenu', function(e) { - $scope.$apply(function() { $scope.showTagMenu(e.tag, e.clientX, e.clientY); }); - }); - - $($scope.tree).bind('hideTagMenu', function(e) { - $scope.$apply(function() { $scope.hideTagMenu(); }); - }); - } - - if ($routeParams.image) { - $scope.setImage($routeParams.image); - } - - return resp.images; - }); - }; - - var loadViewInfo = function() { - fetchRepository(); - listImages(); - }; - - // Fetch the repository itself as well as the image history. - loadViewInfo(); -} - -function BuildPackageCtrl($scope, Restangular, ApiService, DataFileService, $routeParams, $rootScope, $location, $timeout) { - var namespace = $routeParams.namespace; - var name = $routeParams.name; - var buildid = $routeParams.buildid; - - var params = { - 'repository': namespace + '/' + name, - 'build_uuid': buildid - }; - - $scope.initializeTree = function() { - if ($scope.drawn) { - $scope.tree.notifyResized(); - return; - } - - $scope.drawn = true; - $timeout(function() { - $scope.tree.draw('file-tree-container'); - }, 10); - }; - - var determineDockerfilePath = function() { - var dockerfilePath = 'Dockerfile'; - if ($scope.repobuild['job_config']) { - var dockerfileFolder = ($scope.repobuild['job_config']['build_subdir'] || ''); - if (dockerfileFolder[0] == '/') { - dockerfileFolder = dockerfileFolder.substr(1); - } - if (dockerfileFolder && dockerfileFolder[dockerfileFolder.length - 1] != '/') { - dockerfileFolder += '/'; - } - dockerfilePath = dockerfileFolder + 'Dockerfile'; - } - return dockerfilePath; - }; - - var processBuildPack = function(uint8array) { - var archiveread = function(files) { - var getpath = function(file) { - return file.path; - }; - - var findFile = function(path) { - for (var i = 0; i < files.length; ++i) { - var file = files[i]; - if (file.path == path) { - return file; - } - } - return null; - }; - - $scope.tree = new FileTree($.map(files, getpath)); - $($scope.tree).bind('fileClicked', function(e) { - var file = findFile(e.path); - if (file && file.canRead) { - saveAs(file.toBlob(), file.name); - } - }); - - var dockerfilePath = determineDockerfilePath(); - var dockerfile = findFile(dockerfilePath); - if (dockerfile && dockerfile.canRead) { - DataFileService.blobToString(dockerfile.toBlob(), function(result) { - $scope.$apply(function() { - $scope.dockerFilePath = dockerfilePath || 'Dockerfile'; - $scope.dockerFileContents = result; - }); - }); - } - - $scope.loaded = true; - }; - - var notarchive = function() { - DataFileService.arrayToString(uint8array, function(r) { - $scope.dockerFilePath = 'Dockerfile'; - $scope.dockerFileContents = r; - $scope.loaded = true; - }); - }; - - setTimeout(function() { - $scope.$apply(function() { - DataFileService.readDataArrayAsPossibleArchive(uint8array, archiveread, notarchive); - }); - }, 0); - }; - - var downloadBuildPack = function(url) { - $scope.downloadProgress = 0; - $scope.downloading = true; - startDownload(url); - }; - - var startDownload = function(url) { - var onprogress = function(p) { - $scope.downloadProgress = p * 100; - }; - - var onerror = function() { - $scope.downloading = false; - $scope.downloadError = true; - }; - - var onloaded = function(uint8array) { - $scope.downloading = false; - processBuildPack(uint8array); - }; - - DataFileService.downloadDataFileAsArrayBuffer($scope, url, - onprogress, onerror, onloaded); - }; - - var getBuildInfo = function() { - $scope.repository_build = ApiService.getRepoBuildStatus(null, params, true).then(function(resp) { - if (!resp['is_writer']) { - $rootScope.title = 'Unknown build'; - $scope.accessDenied = true; - return; - } - - $rootScope.title = 'Repository Build Pack - ' + resp['display_name']; - $scope.repobuild = resp; - $scope.repo = { - 'namespace': namespace, - 'name': name - }; - - downloadBuildPack(resp['archive_url']); - return resp; - }); - }; - - getBuildInfo(); -} - -function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, TriggerService, $routeParams, - $rootScope, $location, UserService, Config, Features, ExternalNotificationData) { - - var namespace = $routeParams.namespace; - var name = $routeParams.name; - - $scope.Features = Features; - $scope.TriggerService = TriggerService; - $scope.KeyService = KeyService; - - $scope.permissions = {'team': [], 'user': [], 'loading': 2}; - $scope.logsShown = 0; - $scope.deleting = false; - - $scope.permissionCache = {}; - $scope.showTriggerSetupCounter = 0; - - $scope.getBadgeFormat = function(format, repo) { - if (!repo) { return; } - - var imageUrl = Config.getUrl('/repository/' + namespace + '/' + name + '/status'); - if (!$scope.repo.is_public) { - imageUrl += '?token=' + $scope.repo.status_token; - } - - var linkUrl = Config.getUrl('/repository/' + namespace + '/' + name); - - switch (format) { - case 'svg': - return imageUrl; - - case 'md': - return '[![Docker Repository on ' + Config.REGISTRY_TITLE_SHORT + '](' + imageUrl + - ' "Docker Repository on ' + Config.REGISTRY_TITLE_SHORT + '")](' + linkUrl + ')'; - - case 'asciidoc': - return 'image:' + imageUrl + '["Docker Repository on ' + Config.REGISTRY_TITLE_SHORT + '", link="' + linkUrl + '"]'; - } - - return ''; - }; - - $scope.buildEntityForPermission = function(name, permission, kind) { - var key = name + ':' + kind; - if ($scope.permissionCache[key]) { - return $scope.permissionCache[key]; - } - - return $scope.permissionCache[key] = { - 'kind': kind, - 'name': name, - 'is_robot': permission.is_robot, - 'is_org_member': permission.is_org_member - }; - }; - - $scope.loadLogs = function() { - $scope.logsShown++; - }; - - $scope.grantRole = function() { - $('#confirmaddoutsideModal').modal('hide'); - var entity = $scope.currentAddEntity; - $scope.addRole(entity.name, 'read', entity.kind, entity.is_org_member) - $scope.currentAddEntity = null; - }; - - $scope.addNewPermission = function(entity) { - // Don't allow duplicates. - if (!entity || !entity.kind || $scope.permissions[entity.kind][entity.name]) { return; } - - if (entity.is_org_member === false) { - $scope.currentAddEntity = entity; - $('#confirmaddoutsideModal').modal('show'); - return; - } - - $scope.addRole(entity.name, 'read', entity.kind); - }; - - $scope.deleteRole = function(entityName, kind) { - var errorHandler = ApiService.errorDisplay('Cannot change permission', function(resp) { - if (resp.status == 409) { - return 'Cannot change permission as you do not have the authority'; - } - }); - - var permissionDelete = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); - permissionDelete.customDELETE().then(function() { - delete $scope.permissions[kind][entityName]; - }, errorHandler); - }; - - $scope.addRole = function(entityName, role, kind) { - var permission = { - 'role': role, - }; - - var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); - permissionPost.customPUT(permission).then(function(result) { - $scope.permissions[kind][entityName] = result; - }, ApiService.errorDisplay('Cannot change permission')); - }; - - $scope.roles = [ - { 'id': 'read', 'title': 'Read', 'kind': 'success' }, - { 'id': 'write', 'title': 'Write', 'kind': 'success' }, - { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } - ]; - - $scope.setRole = function(role, entityName, kind) { - var permission = $scope.permissions[kind][entityName]; - var currentRole = permission.role; - permission.role = role; - - var permissionPut = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); - permissionPut.customPUT(permission).then(function() {}, function(resp) { - $scope.permissions[kind][entityName] = {'role': currentRole}; - $scope.changePermError = null; - if (resp.status == 409 || resp.data) { - $scope.changePermError = resp.data || ''; - $('#channgechangepermModal').modal({}); - } else { - $('#cannotchangeModal').modal({}); - } - }); - }; - - $scope.newTokenName = null; - - $scope.createToken = function() { - var data = { - 'friendlyName': $scope.newTokenName - }; - - var params = {'repository': namespace + '/' + name}; - ApiService.createToken(data, params).then(function(newToken) { - $scope.newTokenName = null; - $scope.createTokenForm.$setPristine(); - $scope.tokens[newToken.code] = newToken; - }); - }; - - $scope.deleteToken = function(tokenCode) { - var params = { - 'repository': namespace + '/' + name, - 'code': tokenCode - }; - - ApiService.deleteToken(null, params).then(function() { - delete $scope.tokens[tokenCode]; - }); - }; - - $scope.changeTokenAccess = function(tokenCode, newAccess) { - var role = { - 'role': newAccess - }; - - var params = { - 'repository': namespace + '/' + name, - 'code': tokenCode - }; - - ApiService.changeToken(role, params).then(function(updated) { - $scope.tokens[updated.code] = updated; - }); - }; - - $scope.shownTokenCounter = 0; - - $scope.showToken = function(tokenCode) { - $scope.shownToken = $scope.tokens[tokenCode]; - $scope.shownTokenCounter++; - }; - - $scope.askChangeAccess = function(newAccess) { - $('#make' + newAccess + 'Modal').modal({}); - }; - - $scope.changeAccess = function(newAccess) { - $('#make' + newAccess + 'Modal').modal('hide'); - - var visibility = { - 'visibility': newAccess - }; - - var params = { - 'repository': namespace + '/' + name - }; - - ApiService.changeRepoVisibility(visibility, params).then(function() { - $scope.repo.is_public = newAccess == 'public'; - }, function() { - $('#cannotchangeModal').modal({}); - }); - }; - - $scope.askDelete = function() { - $('#confirmdeleteModal').modal({}); - }; - - $scope.deleteRepo = function() { - $('#confirmdeleteModal').modal('hide'); - - var params = { - 'repository': namespace + '/' + name - }; - - $scope.deleting = true; - ApiService.deleteRepository(null, params).then(function() { - $scope.repo = null; - - setTimeout(function() { - document.location = '/repository/'; - }, 1000); - }, function() { - $scope.deleting = true; - $('#cannotchangeModal').modal({}); - }); - }; - - $scope.showNewNotificationCounter = 0; - - $scope.showNewNotificationDialog = function() { - $scope.showNewNotificationCounter++; - }; - - $scope.handleNotificationCreated = function(notification) { - $scope.notifications.push(notification); - }; - - $scope.handleNotificationDeleted = function(notification) { - var index = $.inArray(notification, $scope.notifications); - if (index < 0) { return; } - $scope.notifications.splice(index, 1); - }; - - $scope.loadNotifications = function() { - var params = { - 'repository': namespace + '/' + name - }; - - $scope.notificationsResource = ApiService.listRepoNotificationsAsResource(params).get( - function(resp) { - $scope.notifications = resp.notifications; - return $scope.notifications; - }); - }; - - $scope.showBuild = function(buildInfo) { - $location.path('/repository/' + namespace + '/' + name + '/build'); - $location.search('current', buildInfo.id); - }; - - $scope.loadTriggerBuildHistory = function(trigger) { - trigger.$loadingHistory = true; - - var params = { - 'repository': namespace + '/' + name, - 'trigger_uuid': trigger.id, - 'limit': 3 - }; - - ApiService.listTriggerRecentBuilds(null, params).then(function(resp) { - trigger.$builds = resp['builds']; - trigger.$loadingHistory = false; - }); - }; - - $scope.loadTriggers = function() { - var params = { - 'repository': namespace + '/' + name - }; - - $scope.triggersResource = ApiService.listBuildTriggersAsResource(params).get(function(resp) { - $scope.triggers = resp.triggers; - - // Check to see if we need to setup any trigger. - var newTriggerId = $routeParams.new_trigger; - if (newTriggerId) { - for (var i = 0; i < $scope.triggers.length; ++i) { - var trigger = $scope.triggers[i]; - if (trigger['id'] == newTriggerId && !trigger['is_active']) { - $scope.setupTrigger(trigger); - break; - } - } - } - - return $scope.triggers; - }); - }; - - $scope.setupTrigger = function(trigger) { - $scope.currentSetupTrigger = trigger; - $scope.showTriggerSetupCounter++; - }; - - $scope.cancelSetupTrigger = function(trigger) { - if ($scope.currentSetupTrigger != trigger) { return; } - - $scope.currentSetupTrigger = null; - $scope.deleteTrigger(trigger); - }; - - $scope.showManualBuildDialog = 0; - - $scope.startTrigger = function(trigger, opt_custom) { - var parameters = TriggerService.getRunParameters(trigger.service); - if (parameters.length && !opt_custom) { - $scope.currentStartTrigger = trigger; - $scope.showManualBuildDialog++; - return; - } - - var params = { - 'repository': namespace + '/' + name, - 'trigger_uuid': trigger.id - }; - - ApiService.manuallyStartBuildTrigger(opt_custom || {}, params).then(function(resp) { - var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id']; - document.location = url; - }, ApiService.errorDisplay('Could not start build')); - }; - - $scope.deleteTrigger = function(trigger) { - if (!trigger) { return; } - - var params = { - 'repository': namespace + '/' + name, - 'trigger_uuid': trigger.id - }; - - ApiService.deleteBuildTrigger(null, params).then(function(resp) { - $scope.triggers.splice($scope.triggers.indexOf(trigger), 1); - }); - }; - - var fetchTokens = function() { - var params = { - 'repository': namespace + '/' + name - }; - - ApiService.listRepoTokens(null, params).then(function(resp) { - $scope.tokens = resp.tokens; - }, function() { - $scope.tokens = null; - }); - }; - - var fetchPermissions = function(kind) { - var permissionsFetch = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + kind + '/'); - permissionsFetch.get().then(function(resp) { - $scope.permissions[kind] = resp.permissions; - $scope.permissions['loading']--; - }, function() { - $scope.permissions[kind] = null; - }); - }; - - var fetchRepository = function() { - var params = { - 'repository': namespace + '/' + name - }; - - $scope.repository = ApiService.getRepoAsResource(params).get(function(repo) { - if (!repo.can_admin) { - $rootScope.title = 'Forbidden'; - $scope.accessDenied = true; - return; - } - - $scope.repo = repo; - $rootScope.title = 'Settings - ' + namespace + '/' + name; - $rootScope.description = 'Administrator settings for ' + namespace + '/' + name + - ': Permissions, notifications and other settings'; - - // Fetch all the permissions and token info for the repository. - fetchPermissions('user'); - fetchPermissions('team'); - fetchTokens(); - - $('.info-icon').popover({ - 'trigger': 'hover', - 'html': true - }); - - return $scope.repo; - }); - }; - - // Fetch the repository. - fetchRepository(); -} - -function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, UserService, CookieService, KeyService, - $routeParams, $http, UIService, Features, Config) { - $scope.Features = Features; - - if ($routeParams['migrate']) { - $('#migrateTab').tab('show') - } - - UserService.updateUserIn($scope, function(user) { - $scope.cuser = jQuery.extend({}, user); - - if ($scope.cuser.logins) { - for (var i = 0; i < $scope.cuser.logins.length; i++) { - var login = $scope.cuser.logins[i]; - login.metadata = login.metadata || {}; - - if (login.service == 'github') { - $scope.hasGithubLogin = true; - $scope.githubLogin = login.metadata['service_username']; - $scope.githubEndpoint = KeyService['githubEndpoint']; - } - - if (login.service == 'google') { - $scope.hasGoogleLogin = true; - $scope.googleLogin = login.metadata['service_username']; - } - } - } - }); - - $scope.readyForPlan = function() { - // Show the subscribe dialog if a plan was requested. - return $routeParams['plan']; - }; - - $scope.loading = true; - $scope.updatingUser = false; - $scope.changePasswordSuccess = false; - $scope.changeEmailSent = false; - $scope.convertStep = 0; - $scope.org = {}; - $scope.githubRedirectUri = KeyService.githubRedirectUri; - $scope.authorizedApps = null; - - $scope.logsShown = 0; - $scope.invoicesShown = 0; - - $scope.USER_PATTERN = USER_PATTERN; - - $scope.loadAuthedApps = function() { - if ($scope.authorizedApps) { return; } - - ApiService.listUserAuthorizations().then(function(resp) { - $scope.authorizedApps = resp['authorizations']; - }); - }; - - $scope.deleteAccess = function(accessTokenInfo) { - var params = { - 'access_token_uuid': accessTokenInfo['uuid'] - }; - - ApiService.deleteUserAuthorization(null, params).then(function(resp) { - $scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1); - }, ApiService.errorDisplay('Could not revoke authorization')); - }; - - $scope.loadLogs = function() { - if (!$scope.hasPaidBusinessPlan) { return; } - $scope.logsShown++; - }; - - $scope.loadInvoices = function() { - $scope.invoicesShown++; - }; - - $scope.planChanged = function(plan) { - $scope.hasPaidPlan = plan && plan.price > 0; - $scope.hasPaidBusinessPlan = PlanService.isOrgCompatible(plan) && plan.price > 0; - }; - - $scope.showConvertForm = function() { - if (Features.BILLING) { - PlanService.getMatchingBusinessPlan(function(plan) { - $scope.org.plan = plan; - }); - - PlanService.getPlans(function(plans) { - $scope.orgPlans = plans; - }); - } - - $scope.convertStep = 1; - }; - - $scope.convertToOrg = function() { - $('#reallyconvertModal').modal({}); - }; - - $scope.reallyConvert = function() { - if (Config.AUTHENTICATION_TYPE != 'Database') { return; } - - $scope.loading = true; - - var data = { - 'adminUser': $scope.org.adminUser, - 'adminPassword': $scope.org.adminPassword, - 'plan': $scope.org.plan ? $scope.org.plan.stripeId : '' - }; - - ApiService.convertUserToOrganization(data).then(function(resp) { - CookieService.putPermanent('quay.namespace', $scope.cuser.username); - UserService.load(); - $location.path('/'); - }, function(resp) { - $scope.loading = false; - if (resp.data.reason == 'invaliduser') { - $('#invalidadminModal').modal({}); - } else { - $('#cannotconvertModal').modal({}); - } - }); - }; - - $scope.changeUsername = function() { - UserService.load(); - - $scope.updatingUser = true; - - ApiService.changeUserDetails($scope.cuser).then(function() { - $scope.updatingUser = false; - - // Reset the form. - delete $scope.cuser['username']; - - $scope.changeUsernameForm.$setPristine(); - }, function(result) { - $scope.updatingUser = false; - UIService.showFormError('#changeUsernameForm', result); - }); - }; - - $scope.changeEmail = function() { - UIService.hidePopover('#changeEmailForm'); - - $scope.updatingUser = true; - $scope.changeEmailSent = false; - - ApiService.changeUserDetails($scope.cuser).then(function() { - $scope.updatingUser = false; - $scope.changeEmailSent = true; - $scope.sentEmail = $scope.cuser.email; - - // Reset the form. - delete $scope.cuser['email']; - - $scope.changeEmailForm.$setPristine(); - }, function(result) { - $scope.updatingUser = false; - UIService.showFormError('#changeEmailForm', result); - }); - }; - - $scope.changePassword = function() { - UIService.hidePopover('#changePasswordForm'); - - $scope.updatingUser = true; - $scope.changePasswordSuccess = false; - - ApiService.changeUserDetails($scope.cuser).then(function(resp) { - - $scope.updatingUser = false; - $scope.changePasswordSuccess = true; - - // Reset the form - delete $scope.cuser['password'] - delete $scope.cuser['repeatPassword'] - - $scope.changePasswordForm.$setPristine(); - - // Reload the user. - UserService.load(); - }, function(result) { - $scope.updatingUser = false; - UIService.showFormError('#changePasswordForm', result); - }); - }; - - $scope.detachExternalLogin = function(kind) { - var params = { - 'servicename': kind - }; - - ApiService.detachExternalLogin(null, params).then(function() { - $scope.hasGithubLogin = false; - $scope.hasGoogleLogin = false; - UserService.load(); - }, ApiService.errorDisplay('Count not detach service')); - }; -} - -function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, ImageMetadataService) { - var namespace = $routeParams.namespace; - var name = $routeParams.name; - var imageid = $routeParams.image; - - $scope.getFormattedCommand = ImageMetadataService.getFormattedCommand; - - $scope.parseDate = function(dateString) { - return Date.parse(dateString); - }; - - $scope.getFolder = function(filepath) { - var index = filepath.lastIndexOf('/'); - if (index < 0) { - return ''; - } - return filepath.substr(0, index + 1); - }; - - $scope.getFolders = function(filepath) { - var index = filepath.lastIndexOf('/'); - if (index < 0) { - return ''; - } - - return filepath.substr(0, index).split('/'); - }; - - $scope.getFilename = function(filepath) { - var index = filepath.lastIndexOf('/'); - if (index < 0) { - return filepath; - } - return filepath.substr(index + 1); - }; - - $scope.setFolderFilter = function(folderPath, index) { - var parts = folderPath.split('/'); - parts = parts.slice(0, index + 1); - $scope.setFilter(parts.join('/')); - }; - - $scope.setFilter = function(filter) { - $scope.search = {}; - $scope.search['$'] = filter; - document.getElementById('change-filter').value = filter; - }; - - $scope.initializeTree = function() { - if ($scope.tree) { return; } - - $scope.tree = new ImageFileChangeTree($scope.image, $scope.combinedChanges); - $timeout(function() { - $scope.tree.draw('changes-tree-container'); - }, 10); - }; - - var fetchRepository = function() { - var params = { - 'repository': namespace + '/' + name - }; - - ApiService.getRepoAsResource(params).get(function(repo) { - $scope.repo = repo; - }); - }; - - var fetchImage = function() { - var params = { - 'repository': namespace + '/' + name, - 'image_id': imageid - }; - - $scope.image = ApiService.getImageAsResource(params).get(function(image) { - if (!$scope.repo) { - $scope.repo = { - 'name': name, - 'namespace': namespace, - 'is_public': true - }; - } - - $rootScope.title = 'View Image - ' + image.id; - $rootScope.description = 'Viewing docker image ' + image.id + ' under repository ' + namespace + '/' + name + - ': Image changes tree and list view'; - - // Fetch the image's changes. - fetchChanges(); - return image; - }); - }; - - var fetchChanges = function() { - var params = { - 'repository': namespace + '/' + name, - 'image_id': imageid - }; - - ApiService.getImageChanges(null, params).then(function(changes) { - var combinedChanges = []; - var addCombinedChanges = function(c, kind) { - for (var i = 0; i < c.length; ++i) { - combinedChanges.push({ - 'kind': kind, - 'file': c[i] - }); - } - }; - - addCombinedChanges(changes.added, 'added'); - addCombinedChanges(changes.removed, 'removed'); - addCombinedChanges(changes.changed, 'changed'); - - $scope.combinedChanges = combinedChanges; - $scope.imageChanges = changes; - }); - }; - - // Fetch the repository. - fetchRepository(); - - // Fetch the image. - fetchImage(); -} - -function V1Ctrl($scope, $location, UserService) { - UserService.updateUserIn($scope); -} - -function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService, PlanService, TriggerService, Features) { - UserService.updateUserIn($scope); - - $scope.Features = Features; - - $scope.repo = { - 'is_public': 0, - 'description': '', - 'initialize': '' - }; - - // Watch the namespace on the repo. If it changes, we update the plan and the public/private - // accordingly. - $scope.isUserNamespace = true; - $scope.$watch('repo.namespace', function(namespace) { - // Note: Can initially be undefined. - if (!namespace) { return; } - - var isUserNamespace = (namespace == $scope.user.username); - - $scope.planRequired = null; - $scope.isUserNamespace = isUserNamespace; - - // Determine whether private repositories are allowed for the namespace. - checkPrivateAllowed(); - }); - - $scope.changeNamespace = function(namespace) { - $scope.repo.namespace = namespace; - }; - - $scope.handleBuildStarted = function() { - var repo = $scope.repo; - $location.path('/repository/' + repo.namespace + '/' + repo.name); - }; - - $scope.handleBuildFailed = function(message) { - var repo = $scope.repo; - - bootbox.dialog({ - "message": message, - "title": "Could not start Dockerfile build", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary", - "callback": function() { - $scope.$apply(function() { - $location.path('/repository/' + repo.namespace + '/' + repo.name); - }); - } - } - } - }); - - return true; - }; - - $scope.createNewRepo = function() { - $('#repoName').popover('hide'); - - $scope.creating = true; - var repo = $scope.repo; - var data = { - 'namespace': repo.namespace, - 'repository': repo.name, - 'visibility': repo.is_public == '1' ? 'public' : 'private', - 'description': repo.description - }; - - ApiService.createRepo(data).then(function(created) { - $scope.creating = false; - $scope.created = created; - - // Start the upload process if applicable. - if ($scope.repo.initialize == 'dockerfile' || $scope.repo.initialize == 'zipfile') { - $scope.createdForBuild = created; - return; - } - - // Conduct the Github redirect if applicable. - if ($scope.repo.initialize == 'github') { - window.location = TriggerService.getRedirectUrl('github', repo.namespace, repo.name); - return; - } - - // Otherwise, redirect to the repo page. - $location.path('/repository/' + created.namespace + '/' + created.name); - }, function(result) { - $scope.creating = false; - $scope.createError = result.data ? result.data.message : 'Cannot create repository'; - $timeout(function() { - $('#repoName').popover('show'); - }); - }); - }; - - $scope.upgradePlan = function() { - var callbacks = { - 'started': function() { $scope.planChanging = true; }, - 'opened': function() { $scope.planChanging = true; }, - 'closed': function() { $scope.planChanging = false; }, - 'success': subscribedToPlan, - 'failure': function(resp) { - $('#couldnotsubscribeModal').modal(); - $scope.planChanging = false; - } - }; - - var namespace = $scope.isUserNamespace ? null : $scope.repo.namespace; - PlanService.changePlan($scope, namespace, $scope.planRequired.stripeId, callbacks); - }; - - var checkPrivateAllowed = function() { - if (!$scope.repo || !$scope.repo.namespace) { return; } - - if (!Features.BILLING) { - $scope.checkingPlan = false; - $scope.planRequired = null; - return; - } - - $scope.checkingPlan = true; - - var isUserNamespace = $scope.isUserNamespace; - ApiService.getPrivateAllowed(isUserNamespace ? null : $scope.repo.namespace).then(function(resp) { - $scope.checkingPlan = false; - - if (resp['privateAllowed']) { - $scope.planRequired = null; - return; - } - - if (resp['privateCount'] == null) { - // Organization where we are not the admin. - $scope.planRequired = {}; - return; - } - - // Otherwise, lookup the matching plan. - PlanService.getMinimumPlan(resp['privateCount'] + 1, !isUserNamespace, function(minimum) { - $scope.planRequired = minimum; - }); - }); - }; - - var subscribedToPlan = function(sub) { - $scope.planChanging = false; - $scope.subscription = sub; - - PlanService.getPlan(sub.plan, function(subscribedPlan) { - $scope.subscribedPlan = subscribedPlan; - $scope.planRequired = null; - checkPrivateAllowed(); - }); - }; -} - -function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams) { - var orgname = $routeParams.orgname; - - $scope.TEAM_PATTERN = TEAM_PATTERN; - $rootScope.title = 'Loading...'; - - $scope.teamRoles = [ - { 'id': 'member', 'title': 'Member', 'kind': 'default' }, - { 'id': 'creator', 'title': 'Creator', 'kind': 'success' }, - { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } - ]; - - $scope.setRole = function(role, teamname) { - var previousRole = $scope.organization.teams[teamname].role; - $scope.organization.teams[teamname].role = role; - - var params = { - 'orgname': orgname, - 'teamname': teamname - }; - - var data = $scope.organization.teams[teamname]; - - ApiService.updateOrganizationTeam(data, params).then(function(resp) { - }, function(resp) { - $scope.organization.teams[teamname].role = previousRole; - $scope.roleError = resp.data || ''; - $('#cannotChangeTeamModal').modal({}); - }); - }; - - $scope.createTeam = function(teamname) { - if (!teamname) { - return; - } - - if ($scope.organization.teams[teamname]) { - $('#team-' + teamname).removeClass('highlight'); - setTimeout(function() { - $('#team-' + teamname).addClass('highlight'); - }, 10); - return; - } - - createOrganizationTeam(ApiService, orgname, teamname, function(created) { - $scope.organization.teams[teamname] = created; - }); - }; - - $scope.askDeleteTeam = function(teamname) { - $scope.currentDeleteTeam = teamname; - $('#confirmdeleteModal').modal({}); - }; - - $scope.deleteTeam = function() { - $('#confirmdeleteModal').modal('hide'); - if (!$scope.currentDeleteTeam) { return; } - - var teamname = $scope.currentDeleteTeam; - var params = { - 'orgname': orgname, - 'teamname': teamname - }; - - var errorHandler = ApiService.errorDisplay('Cannot delete team', function() { - $scope.currentDeleteTeam = null; - }); - - ApiService.deleteOrganizationTeam(null, params).then(function() { - delete $scope.organization.teams[teamname]; - $scope.currentDeleteTeam = null; - }, errorHandler); - }; - - var loadOrganization = function() { - $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { - $scope.organization = org; - $rootScope.title = orgname; - $rootScope.description = 'Viewing organization ' + orgname; - - $('.info-icon').popover({ - 'trigger': 'hover', - 'html': true - }); - }); - }; - - // Load the organization. - loadOrganization(); -} - -function OrgAdminCtrl($rootScope, $scope, $timeout, Restangular, $routeParams, UserService, PlanService, ApiService, Features, UIService) { - var orgname = $routeParams.orgname; - - // Load the list of plans. - if (Features.BILLING) { - PlanService.getPlans(function(plans) { - $scope.plans = plans; - $scope.plan_map = {}; - - for (var i = 0; i < plans.length; ++i) { - $scope.plan_map[plans[i].stripeId] = plans[i]; - } - }); - } - - $scope.orgname = orgname; - $scope.membersLoading = true; - $scope.membersFound = null; - $scope.invoiceLoading = true; - $scope.logsShown = 0; - $scope.invoicesShown = 0; - $scope.applicationsShown = 0; - $scope.changingOrganization = false; - - $scope.loadLogs = function() { - $scope.logsShown++; - }; - - $scope.loadApplications = function() { - $scope.applicationsShown++; - }; - - $scope.loadInvoices = function() { - $scope.invoicesShown++; - }; - - $scope.planChanged = function(plan) { - $scope.hasPaidPlan = plan && plan.price > 0; - }; - - $scope.$watch('organizationEmail', function(e) { - UIService.hidePopover('#changeEmailForm'); - }); - - $scope.changeEmail = function() { - UIService.hidePopover('#changeEmailForm'); - - $scope.changingOrganization = true; - var params = { - 'orgname': orgname - }; - - var data = { - 'email': $scope.organizationEmail - }; - - ApiService.changeOrganizationDetails(data, params).then(function(org) { - $scope.changingOrganization = false; - $scope.changeEmailForm.$setPristine(); - $scope.organization = org; - }, function(result) { - $scope.changingOrganization = false; - UIService.showFormError('#changeEmailForm', result); - }); - }; - - $scope.loadMembers = function() { - if ($scope.membersFound) { return; } - $scope.membersLoading = true; - - var params = { - 'orgname': orgname - }; - - ApiService.getOrganizationMembers(null, params).then(function(resp) { - var membersArray = []; - for (var key in resp.members) { - if (resp.members.hasOwnProperty(key)) { - membersArray.push(resp.members[key]); - } - } - - $scope.membersFound = membersArray; - $scope.membersLoading = false; - }); - }; - - var loadOrganization = function() { - $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { - if (org && org.is_admin) { - $scope.organization = org; - $scope.organizationEmail = org.email; - $rootScope.title = orgname + ' (Admin)'; - $rootScope.description = 'Administration page for organization ' + orgname; - } - }); - }; - - // Load the organization. - loadOrganization(); -} - -function TeamViewCtrl($rootScope, $scope, $timeout, Features, Restangular, ApiService, $routeParams) { - var teamname = $routeParams.teamname; - var orgname = $routeParams.orgname; - - $scope.orgname = orgname; - $scope.teamname = teamname; - $scope.addingMember = false; - $scope.memberMap = null; - $scope.allowEmail = Features.MAILING; - - $rootScope.title = 'Loading...'; - - $scope.filterFunction = function(invited, robots) { - return function(item) { - // Note: The !! is needed because is_robot will be undefined for invites. - var robot_check = (!!item.is_robot == robots); - return robot_check && item.invited == invited; - }; - }; - - $scope.inviteEmail = function(email) { - if (!email || $scope.memberMap[email]) { return; } - - $scope.addingMember = true; - - var params = { - 'orgname': orgname, - 'teamname': teamname, - 'email': email - }; - - var errorHandler = ApiService.errorDisplay('Cannot invite team member', function() { - $scope.addingMember = false; - }); - - ApiService.inviteTeamMemberEmail(null, params).then(function(resp) { - $scope.members.push(resp); - $scope.memberMap[resp.email] = resp; - $scope.addingMember = false; - }, errorHandler); - }; - - $scope.addNewMember = function(member) { - if (!member || $scope.memberMap[member.name]) { return; } - - var params = { - 'orgname': orgname, - 'teamname': teamname, - 'membername': member.name - }; - - var errorHandler = ApiService.errorDisplay('Cannot add team member', function() { - $scope.addingMember = false; - }); - - $scope.addingMember = true; - ApiService.updateOrganizationTeamMember(null, params).then(function(resp) { - $scope.members.push(resp); - $scope.memberMap[resp.name] = resp; - $scope.addingMember = false; - }, errorHandler); - }; - - $scope.revokeInvite = function(inviteInfo) { - if (inviteInfo.kind == 'invite') { - // E-mail invite. - $scope.revokeEmailInvite(inviteInfo.email); - } else { - // User invite. - $scope.removeMember(inviteInfo.name); - } - }; - - $scope.revokeEmailInvite = function(email) { - var params = { - 'orgname': orgname, - 'teamname': teamname, - 'email': email - }; - - ApiService.deleteTeamMemberEmailInvite(null, params).then(function(resp) { - if (!$scope.memberMap[email]) { return; } - var index = $.inArray($scope.memberMap[email], $scope.members); - $scope.members.splice(index, 1); - delete $scope.memberMap[email]; - }, ApiService.errorDisplay('Cannot revoke team invite')); - }; - - $scope.removeMember = function(username) { - var params = { - 'orgname': orgname, - 'teamname': teamname, - 'membername': username - }; - - ApiService.deleteOrganizationTeamMember(null, params).then(function(resp) { - if (!$scope.memberMap[username]) { return; } - var index = $.inArray($scope.memberMap[username], $scope.members); - $scope.members.splice(index, 1); - delete $scope.memberMap[username]; - }, ApiService.errorDisplay('Cannot remove team member')); - }; - - $scope.updateForDescription = function(content) { - $scope.organization.teams[teamname].description = content; - - var params = { - 'orgname': orgname, - 'teamname': teamname - }; - - var teaminfo = $scope.organization.teams[teamname]; - ApiService.updateOrganizationTeam(teaminfo, params).then(function(resp) { - }, function() { - $('#cannotChangeTeamModal').modal({}); - }); - }; - - var loadOrganization = function() { - $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { - $scope.organization = org; - $scope.team = $scope.organization.teams[teamname]; - $rootScope.title = teamname + ' (' + $scope.orgname + ')'; - $rootScope.description = 'Team management page for team ' + teamname + ' under organization ' + $scope.orgname; - loadMembers(); - return org; - }); - }; - - var loadMembers = function() { - var params = { - 'orgname': orgname, - 'teamname': teamname, - 'includePending': true - }; - - $scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) { - $scope.members = resp.members; - $scope.canEditMembers = resp.can_edit; - - $('.info-icon').popover({ - 'trigger': 'hover', - 'html': true - }); - - $scope.memberMap = {}; - for (var i = 0; i < $scope.members.length; ++i) { - var current = $scope.members[i]; - $scope.memberMap[current.name || current.email] = current; - } - - return resp.members; - }); - }; - - // Load the organization. - loadOrganization(); -} - -function OrgsCtrl($scope, UserService) { - UserService.updateUserIn($scope); - browserchrome.update(); -} - -function NewOrgCtrl($scope, $routeParams, $timeout, $location, UserService, PlanService, ApiService, CookieService, Features) { - $scope.Features = Features; - $scope.holder = {}; - - UserService.updateUserIn($scope); - - var requested = $routeParams['plan']; - - if (Features.BILLING) { - // Load the list of plans. - PlanService.getPlans(function(plans) { - $scope.plans = plans; - $scope.holder.currentPlan = null; - if (requested) { - PlanService.getPlan(requested, function(plan) { - $scope.holder.currentPlan = plan; - }); - } - }); - } - - $scope.signedIn = function() { - if (Features.BILLING) { - PlanService.handleNotedPlan(); - } - }; - - $scope.signinStarted = function() { - if (Features.BILLING) { - PlanService.getMinimumPlan(1, true, function(plan) { - PlanService.notePlan(plan.stripeId); - }); - } - }; - - $scope.setPlan = function(plan) { - $scope.holder.currentPlan = plan; - }; - - $scope.createNewOrg = function() { - $('#orgName').popover('hide'); - - $scope.creating = true; - var org = $scope.org; - var data = { - 'name': org.name, - 'email': org.email - }; - - ApiService.createOrganization(data).then(function(created) { - $scope.created = created; - - // Reset the organizations list. - UserService.load(); - - // Set the default namesapce to the organization. - CookieService.putPermanent('quay.namespace', org.name); - - var showOrg = function() { - $scope.creating = false; - $location.path('/organization/' + org.name + '/'); - }; - - // If the selected plan is free, simply move to the org page. - if (!Features.BILLING || $scope.holder.currentPlan.price == 0) { - showOrg(); - return; - } - - // Otherwise, show the subscribe for the plan. - $scope.creating = true; - var callbacks = { - 'opened': function() { $scope.creating = true; }, - 'closed': showOrg, - 'success': showOrg, - 'failure': showOrg - }; - - PlanService.changePlan($scope, org.name, $scope.holder.currentPlan.stripeId, callbacks); - }, function(resp) { - $scope.creating = false; - $scope.createError = ApiService.getErrorMessage(resp); - $timeout(function() { - $('#orgName').popover('show'); - }); - }); - }; -} - - -function OrgMemberLogsCtrl($scope, $routeParams, $rootScope, $timeout, Restangular, ApiService) { - var orgname = $routeParams.orgname; - var membername = $routeParams.membername; - - $scope.orgname = orgname; - $scope.memberInfo = null; - $scope.ready = false; - - var loadOrganization = function() { - $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { - $scope.organization = org; - return org; - }); - }; - - var loadMemberInfo = function() { - var params = { - 'orgname': orgname, - 'membername': membername - }; - - $scope.memberResource = ApiService.getOrganizationMemberAsResource(params).get(function(resp) { - $scope.memberInfo = resp.member; - - $rootScope.title = 'Logs for ' + $scope.memberInfo.name + ' (' + $scope.orgname + ')'; - $rootScope.description = 'Shows all the actions of ' + $scope.memberInfo.username + - ' under organization ' + $scope.orgname; - - $timeout(function() { - $scope.ready = true; - }); - - return resp.member; - }); - }; - - // Load the org info and the member info. - loadOrganization(); - loadMemberInfo(); -} - - -function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $timeout, OAuthService, ApiService, UserService, Config) { - var orgname = $routeParams.orgname; - var clientId = $routeParams.clientid; - - $scope.Config = Config; - $scope.OAuthService = OAuthService; - $scope.updating = false; - - $scope.genScopes = {}; - - UserService.updateUserIn($scope); - - $scope.getScopes = function(scopes) { - var checked = []; - for (var scopeName in scopes) { - if (scopes.hasOwnProperty(scopeName) && scopes[scopeName]) { - checked.push(scopeName); - } - } - return checked; - }; - - $scope.askResetClientSecret = function() { - $('#resetSecretModal').modal({}); - }; - - $scope.askDelete = function() { - $('#deleteAppModal').modal({}); - }; - - $scope.deleteApplication = function() { - var params = { - 'orgname': orgname, - 'client_id': clientId - }; - - $('#deleteAppModal').modal('hide'); - - ApiService.deleteOrganizationApplication(null, params).then(function(resp) { - $timeout(function() { - $location.path('/organization/' + orgname + '/admin'); - }, 500); - }, ApiService.errorDisplay('Could not delete application')); - }; - - $scope.updateApplication = function() { - $scope.updating = true; - var params = { - 'orgname': orgname, - 'client_id': clientId - }; - - if (!$scope.application['description']) { - delete $scope.application['description']; - } - - if (!$scope.application['avatar_email']) { - delete $scope.application['avatar_email']; - } - - var errorHandler = ApiService.errorDisplay('Could not update application', function(resp) { - $scope.updating = false; - }); - - ApiService.updateOrganizationApplication($scope.application, params).then(function(resp) { - $scope.application = resp; - }, errorHandler); - }; - - $scope.resetClientSecret = function() { - var params = { - 'orgname': orgname, - 'client_id': clientId - }; - - $('#resetSecretModal').modal('hide'); - - ApiService.resetOrganizationApplicationClientSecret(null, params).then(function(resp) { - $scope.application = resp; - }, ApiService.errorDisplay('Could not reset client secret')); - }; - - var loadOrganization = function() { - $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { - $scope.organization = org; - return org; - }); - }; - - var loadApplicationInfo = function() { - var params = { - 'orgname': orgname, - 'client_id': clientId - }; - - $scope.appResource = ApiService.getOrganizationApplicationAsResource(params).get(function(resp) { - $scope.application = resp; - - $rootScope.title = 'Manage Application ' + $scope.application.name + ' (' + $scope.orgname + ')'; - $rootScope.description = 'Manage the details of application ' + $scope.application.name + - ' under organization ' + $scope.orgname; - - return resp; - }); - }; - - - // Load the organization and application info. - loadOrganization(); - loadApplicationInfo(); -} - -function TourCtrl($scope, $location) { - $scope.kind = $location.path().substring('/tour/'.length); -} - -function ConfirmInviteCtrl($scope, $location, UserService, ApiService, NotificationService) { - // Monitor any user changes and place the current user into the scope. - $scope.loading = false; - $scope.inviteCode = $location.search()['code'] || ''; - - UserService.updateUserIn($scope, function(user) { - if (!user.anonymous && !$scope.loading) { - // Make sure to not redirect now that we have logged in. We'll conduct the redirect - // manually. - $scope.redirectUrl = null; - $scope.loading = true; - - var params = { - 'code': $location.search()['code'] - }; - - ApiService.acceptOrganizationTeamInvite(null, params).then(function(resp) { - NotificationService.update(); - UserService.load(); - $location.path('/organization/' + resp.org + '/teams/' + resp.team); - }, function(resp) { - $scope.loading = false; - $scope.invalid = ApiService.getErrorMessage(resp, 'Invalid confirmation code'); - }); - } - }); - - $scope.redirectUrl = window.location.href; -} diff --git a/static/js/controllers/repo-build.js b/static/js/controllers/repo-build.js deleted file mode 100644 index 887efb55b..000000000 --- a/static/js/controllers/repo-build.js +++ /dev/null @@ -1,272 +0,0 @@ -function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $location, $interval, $sanitize, - ansi2html, AngularViewArray, AngularPollChannel) { - var namespace = $routeParams.namespace; - var name = $routeParams.name; - - // Watch for changes to the current parameter. - $scope.$on('$routeUpdate', function(){ - if ($location.search().current) { - $scope.setCurrentBuild($location.search().current, false); - } - }); - - $scope.builds = null; - $scope.pollChannel = null; - $scope.buildDialogShowCounter = 0; - - $scope.showNewBuildDialog = function() { - $scope.buildDialogShowCounter++; - }; - - $scope.handleBuildStarted = function(newBuild) { - if (!$scope.builds) { return; } - - $scope.builds.unshift(newBuild); - $scope.setCurrentBuild(newBuild['id'], true); - }; - - $scope.adjustLogHeight = function() { - var triggerOffset = 0; - if ($scope.currentBuild && $scope.currentBuild.trigger) { - triggerOffset = 85; - } - $('.build-logs').height($(window).height() - 415 - triggerOffset); - }; - - $scope.askRestartBuild = function(build) { - $('#confirmRestartBuildModal').modal({}); - }; - - $scope.askCancelBuild = function(build) { - bootbox.confirm('Are you sure you want to cancel this build?', function(r) { - if (r) { - var params = { - 'repository': namespace + '/' + name, - 'build_uuid': build.id - }; - - ApiService.cancelRepoBuild(null, params).then(function() { - if (!$scope.builds) { return; } - $scope.builds.splice($.inArray(build, $scope.builds), 1); - - if ($scope.builds.length) { - $scope.currentBuild = $scope.builds[0]; - } else { - $scope.currentBuild = null; - } - }, ApiService.errorDisplay('Cannot cancel build')); - } - }); - }; - - $scope.restartBuild = function(build) { - $('#confirmRestartBuildModal').modal('hide'); - - var subdirectory = ''; - if (build['job_config']) { - subdirectory = build['job_config']['build_subdir'] || ''; - } - - var data = { - 'file_id': build['resource_key'], - 'subdirectory': subdirectory, - 'docker_tags': build['job_config']['docker_tags'] - }; - - if (build['pull_robot']) { - data['pull_robot'] = build['pull_robot']['name']; - } - - var params = { - 'repository': namespace + '/' + name - }; - - ApiService.requestRepoBuild(data, params).then(function(newBuild) { - if (!$scope.builds) { return; } - - $scope.builds.unshift(newBuild); - $scope.setCurrentBuild(newBuild['id'], true); - }); - }; - - $scope.hasLogs = function(container) { - return container.logs.hasEntries; - }; - - $scope.setCurrentBuild = function(buildId, opt_updateURL) { - if (!$scope.builds) { return; } - - // Find the build. - for (var i = 0; i < $scope.builds.length; ++i) { - if ($scope.builds[i].id == buildId) { - $scope.setCurrentBuildInternal(i, $scope.builds[i], opt_updateURL); - return; - } - } - }; - - $scope.processANSI = function(message, container) { - var filter = container.logs._filter = (container.logs._filter || ansi2html.create()); - - // Note: order is important here. - var setup = filter.getSetupHtml(); - var stream = filter.addInputToStream(message); - var teardown = filter.getTeardownHtml(); - return setup + stream + teardown; - }; - - $scope.setCurrentBuildInternal = function(index, build, opt_updateURL) { - if (build == $scope.currentBuild) { return; } - - $scope.logEntries = null; - $scope.logStartIndex = null; - $scope.currentParentEntry = null; - - $scope.currentBuild = build; - - if (opt_updateURL) { - if (build) { - $location.search('current', build.id); - } else { - $location.search('current', null); - } - } - - // Timeout needed to ensure the log element has been created - // before its height is adjusted. - setTimeout(function() { - $scope.adjustLogHeight(); - }, 1); - - // Stop any existing polling. - if ($scope.pollChannel) { - $scope.pollChannel.stop(); - } - - // Create a new channel for polling the build status and logs. - var conductStatusAndLogRequest = function(callback) { - getBuildStatusAndLogs(build, callback); - }; - - $scope.pollChannel = AngularPollChannel.create($scope, conductStatusAndLogRequest, 5 * 1000 /* 5s */); - $scope.pollChannel.start(); - }; - - var processLogs = function(logs, startIndex, endIndex) { - if (!$scope.logEntries) { $scope.logEntries = []; } - - // If the start index given is less than that requested, then we've received a larger - // pool of logs, and we need to only consider the new ones. - if (startIndex < $scope.logStartIndex) { - logs = logs.slice($scope.logStartIndex - startIndex); - } - - for (var i = 0; i < logs.length; ++i) { - var entry = logs[i]; - var type = entry['type'] || 'entry'; - if (type == 'command' || type == 'phase' || type == 'error') { - entry['logs'] = AngularViewArray.create(); - entry['index'] = $scope.logStartIndex + i; - - $scope.logEntries.push(entry); - $scope.currentParentEntry = entry; - } else if ($scope.currentParentEntry) { - $scope.currentParentEntry['logs'].push(entry); - } - } - - return endIndex; - }; - - var getBuildStatusAndLogs = function(build, callback) { - var params = { - 'repository': namespace + '/' + name, - 'build_uuid': build.id - }; - - ApiService.getRepoBuildStatus(null, params, true).then(function(resp) { - if (build != $scope.currentBuild) { callback(false); return; } - - // Note: We use extend here rather than replacing as Angular is depending on the - // root build object to remain the same object. - var matchingBuilds = $.grep($scope.builds, function(elem) { - return elem['id'] == resp['id'] - }); - - var currentBuild = matchingBuilds.length > 0 ? matchingBuilds[0] : null; - if (currentBuild) { - currentBuild = $.extend(true, currentBuild, resp); - } else { - currentBuild = resp; - $scope.builds.push(currentBuild); - } - - // Load the updated logs for the build. - var options = { - 'start': $scope.logStartIndex - }; - - ApiService.getRepoBuildLogsAsResource(params, true).withOptions(options).get(function(resp) { - if (build != $scope.currentBuild) { callback(false); return; } - - // Process the logs we've received. - $scope.logStartIndex = processLogs(resp['logs'], resp['start'], resp['total']); - - // If the build status is an error, open the last two log entries. - if (currentBuild['phase'] == 'error' && $scope.logEntries.length > 1) { - var openLogEntries = function(entry) { - if (entry.logs) { - entry.logs.setVisible(true); - } - }; - - openLogEntries($scope.logEntries[$scope.logEntries.length - 2]); - openLogEntries($scope.logEntries[$scope.logEntries.length - 1]); - } - - // If the build phase is an error or a complete, then we mark the channel - // as closed. - callback(currentBuild['phase'] != 'error' && currentBuild['phase'] != 'complete'); - }, function() { - callback(false); - }); - }, function() { - callback(false); - }); - }; - - var fetchRepository = function() { - var params = {'repository': namespace + '/' + name}; - $rootScope.title = 'Loading Repository...'; - $scope.repository = ApiService.getRepoAsResource(params).get(function(repo) { - if (!repo.can_write) { - $rootScope.title = 'Unknown builds'; - $scope.accessDenied = true; - return; - } - - $rootScope.title = 'Repository Builds'; - $scope.repo = repo; - - getBuildInfo(); - }); - }; - - var getBuildInfo = function(repo) { - var params = { - 'repository': namespace + '/' + name - }; - - ApiService.getRepoBuilds(null, params).then(function(resp) { - $scope.builds = resp.builds; - - if ($location.search().current) { - $scope.setCurrentBuild($location.search().current, false); - } else if ($scope.builds.length > 0) { - $scope.setCurrentBuild($scope.builds[0].id, true); - } - }); - }; - - fetchRepository(); -} \ No newline at end of file diff --git a/static/js/controllers/setup.js b/static/js/controllers/setup.js deleted file mode 100644 index 9dc76a17f..000000000 --- a/static/js/controllers/setup.js +++ /dev/null @@ -1,282 +0,0 @@ -function SetupCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, CoreDialog) { - if (!Features.SUPER_USERS) { - return; - } - - $scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9\.]+(:[0-9]+)?$'; - - $scope.validateHostname = function(hostname) { - if (hostname.indexOf('127.0.0.1') == 0 || hostname.indexOf('localhost') == 0) { - return 'Please specify a non-localhost hostname. "localhost" will refer to the container, not your machine.' - } - - return null; - }; - - // Note: The values of the enumeration are important for isStepFamily. For example, - // *all* states under the "configuring db" family must start with "config-db". - $scope.States = { - // Loading the state of the product. - 'LOADING': 'loading', - - // The configuration directory is missing. - 'MISSING_CONFIG_DIR': 'missing-config-dir', - - // The config.yaml exists but it is invalid. - 'INVALID_CONFIG': 'config-invalid', - - // DB is being configured. - 'CONFIG_DB': 'config-db', - - // DB information is being validated. - 'VALIDATING_DB': 'config-db-validating', - - // DB information is being saved to the config. - 'SAVING_DB': 'config-db-saving', - - // A validation error occurred with the database. - 'DB_ERROR': 'config-db-error', - - // Database is being setup. - 'DB_SETUP': 'setup-db', - - // Database setup has succeeded. - 'DB_SETUP_SUCCESS': 'setup-db-success', - - // An error occurred when setting up the database. - 'DB_SETUP_ERROR': 'setup-db-error', - - // The container is being restarted for the database changes. - 'DB_RESTARTING': 'setup-db-restarting', - - // A superuser is being configured. - 'CREATE_SUPERUSER': 'create-superuser', - - // The superuser is being created. - 'CREATING_SUPERUSER': 'create-superuser-creating', - - // An error occurred when setting up the superuser. - 'SUPERUSER_ERROR': 'create-superuser-error', - - // The superuser was created successfully. - 'SUPERUSER_CREATED': 'create-superuser-created', - - // General configuration is being setup. - 'CONFIG': 'config', - - // The configuration is fully valid. - 'VALID_CONFIG': 'valid-config', - - // The container is being restarted for the configuration changes. - 'CONFIG_RESTARTING': 'config-restarting', - - // The product is ready for use. - 'READY': 'ready' - } - - $scope.csrf_token = window.__token; - $scope.currentStep = $scope.States.LOADING; - $scope.errors = {}; - $scope.stepProgress = []; - $scope.hasSSL = false; - $scope.hostname = null; - - $scope.$watch('currentStep', function(currentStep) { - $scope.stepProgress = $scope.getProgress(currentStep); - - switch (currentStep) { - case $scope.States.CONFIG: - $('#setupModal').modal('hide'); - break; - - case $scope.States.MISSING_CONFIG_DIR: - $scope.showMissingConfigDialog(); - break; - - case $scope.States.INVALID_CONFIG: - $scope.showInvalidConfigDialog(); - break; - - case $scope.States.DB_SETUP: - $scope.performDatabaseSetup(); - // Fall-through. - - case $scope.States.CREATE_SUPERUSER: - case $scope.States.DB_RESTARTING: - case $scope.States.CONFIG_DB: - case $scope.States.VALID_CONFIG: - case $scope.States.READY: - $('#setupModal').modal({ - keyboard: false, - backdrop: 'static' - }); - break; - } - }); - - $scope.restartContainer = function(state) { - $scope.currentStep = state; - ContainerService.restartContainer(function() { - $scope.checkStatus() - }); - }; - - $scope.showSuperuserPanel = function() { - $('#setupModal').modal('hide'); - var prefix = $scope.hasSSL ? 'https' : 'http'; - var hostname = $scope.hostname; - window.location = prefix + '://' + hostname + '/superuser'; - }; - - $scope.configurationSaved = function(config) { - $scope.hasSSL = config['PREFERRED_URL_SCHEME'] == 'https'; - $scope.hostname = config['SERVER_HOSTNAME']; - $scope.currentStep = $scope.States.VALID_CONFIG; - }; - - $scope.getProgress = function(step) { - var isStep = $scope.isStep; - var isStepFamily = $scope.isStepFamily; - var States = $scope.States; - - return [ - isStepFamily(step, States.CONFIG_DB), - isStepFamily(step, States.DB_SETUP), - isStep(step, States.DB_RESTARTING), - isStepFamily(step, States.CREATE_SUPERUSER), - isStep(step, States.CONFIG), - isStep(step, States.VALID_CONFIG), - isStep(step, States.CONFIG_RESTARTING), - isStep(step, States.READY) - ]; - }; - - $scope.isStepFamily = function(step, family) { - if (!step) { return false; } - return step.indexOf(family) == 0; - }; - - $scope.isStep = function(step) { - for (var i = 1; i < arguments.length; ++i) { - if (arguments[i] == step) { - return true; - } - } - return false; - }; - - $scope.showInvalidConfigDialog = function() { - var message = "The config.yaml file found in conf/stack could not be parsed." - var title = "Invalid configuration file"; - CoreDialog.fatal(title, message); - }; - - - $scope.showMissingConfigDialog = function() { - var message = "A volume should be mounted into the container at /conf/stack: " + - "

docker run -v /path/to/config:/conf/stack
" + - "
Once fixed, restart the container. For more information, " + - "" + - "Read the Setup Guide" - - var title = "Missing configuration volume"; - CoreDialog.fatal(title, message); - }; - - $scope.parseDbUri = function(value) { - if (!value) { return null; } - - // Format: mysql+pymysql://:@/ - var uri = URI(value); - return { - 'kind': uri.protocol(), - 'username': uri.username(), - 'password': uri.password(), - 'server': uri.host(), - 'database': uri.path() ? uri.path().substr(1) : '' - }; - }; - - $scope.serializeDbUri = function(fields) { - if (!fields['server']) { return ''; } - - try { - if (!fields['server']) { return ''; } - if (!fields['database']) { return ''; } - - var uri = URI(); - uri = uri && uri.host(fields['server']); - uri = uri && uri.protocol(fields['kind']); - uri = uri && uri.username(fields['username']); - uri = uri && uri.password(fields['password']); - uri = uri && uri.path('/' + (fields['database'] || '')); - uri = uri && uri.toString(); - } catch (ex) { - return ''; - } - - return uri; - }; - - $scope.createSuperUser = function() { - $scope.currentStep = $scope.States.CREATING_SUPERUSER; - ApiService.scCreateInitialSuperuser($scope.superUser, null).then(function(resp) { - UserService.load(); - $scope.checkStatus(); - }, function(resp) { - $scope.currentStep = $scope.States.SUPERUSER_ERROR; - $scope.errors.SuperuserCreationError = ApiService.getErrorMessage(resp, 'Could not create superuser'); - }); - }; - - $scope.performDatabaseSetup = function() { - $scope.currentStep = $scope.States.DB_SETUP; - ApiService.scSetupDatabase(null, null).then(function(resp) { - if (resp['error']) { - $scope.currentStep = $scope.States.DB_SETUP_ERROR; - $scope.errors.DatabaseSetupError = resp['error']; - } else { - $scope.currentStep = $scope.States.DB_SETUP_SUCCESS; - } - }, ApiService.errorDisplay('Could not setup database. Please report this to support.')) - }; - - $scope.validateDatabase = function() { - $scope.currentStep = $scope.States.VALIDATING_DB; - $scope.databaseInvalid = null; - - var data = { - 'config': { - 'DB_URI': $scope.databaseUri - }, - 'hostname': window.location.host - }; - - var params = { - 'service': 'database' - }; - - ApiService.scValidateConfig(data, params).then(function(resp) { - var status = resp.status; - - if (status) { - $scope.currentStep = $scope.States.SAVING_DB; - ApiService.scUpdateConfig(data, null).then(function(resp) { - $scope.checkStatus(); - }, ApiService.errorDisplay('Cannot update config. Please report this to support')); - } else { - $scope.currentStep = $scope.States.DB_ERROR; - $scope.errors.DatabaseValidationError = resp.reason; - } - }, ApiService.errorDisplay('Cannot validate database. Please report this to support')); - }; - - $scope.checkStatus = function() { - ContainerService.checkStatus(function(resp) { - $scope.currentStep = resp['status']; - }, $scope.hasSSL); - }; - - // Load the initial status. - $scope.checkStatus(); -} \ No newline at end of file diff --git a/static/js/controllers/superuser.js b/static/js/controllers/superuser.js deleted file mode 100644 index f867cd43b..000000000 --- a/static/js/controllers/superuser.js +++ /dev/null @@ -1,229 +0,0 @@ -function SuperUserAdminCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, AngularPollChannel, CoreDialog) { - if (!Features.SUPER_USERS) { - return; - } - - // Monitor any user changes and place the current user into the scope. - UserService.updateUserIn($scope); - - $scope.configStatus = null; - $scope.requiresRestart = null; - $scope.logsCounter = 0; - $scope.newUser = {}; - $scope.createdUser = null; - $scope.systemUsage = null; - $scope.debugServices = null; - $scope.debugLogs = null; - $scope.pollChannel = null; - $scope.logsScrolled = false; - $scope.csrf_token = encodeURIComponent(window.__token); - $scope.dashboardActive = false; - - $scope.setDashboardActive = function(active) { - $scope.dashboardActive = active; - }; - - $scope.configurationSaved = function() { - $scope.requiresRestart = true; - }; - - $scope.showCreateUser = function() { - $scope.createdUser = null; - $('#createUserModal').modal('show'); - }; - - $scope.viewSystemLogs = function(service) { - if ($scope.pollChannel) { - $scope.pollChannel.stop(); - } - - $scope.debugService = service; - $scope.debugLogs = null; - - $scope.pollChannel = AngularPollChannel.create($scope, $scope.loadServiceLogs, 2 * 1000 /* 2s */); - $scope.pollChannel.start(); - }; - - $scope.loadServiceLogs = function(callback) { - if (!$scope.debugService) { return; } - - var params = { - 'service': $scope.debugService - }; - - var errorHandler = ApiService.errorDisplay('Cannot load system logs. Please contact support.', - function() { - callback(false); - }) - - ApiService.getSystemLogs(null, params, /* background */true).then(function(resp) { - $scope.debugLogs = resp['logs']; - callback(true); - }, errorHandler); - }; - - $scope.loadDebugServices = function() { - if ($scope.pollChannel) { - $scope.pollChannel.stop(); - } - - $scope.debugService = null; - - ApiService.listSystemLogServices().then(function(resp) { - $scope.debugServices = resp['services']; - }, ApiService.errorDisplay('Cannot load system logs. Please contact support.')) - }; - - $scope.getUsage = function() { - if ($scope.systemUsage) { return; } - - ApiService.getSystemUsage().then(function(resp) { - $scope.systemUsage = resp; - }, ApiService.errorDisplay('Cannot load system usage. Please contact support.')) - } - - $scope.loadUsageLogs = function() { - $scope.logsCounter++; - }; - - $scope.loadUsers = function() { - if ($scope.users) { - return; - } - - $scope.loadUsersInternal(); - }; - - $scope.loadUsersInternal = function() { - ApiService.listAllUsers().then(function(resp) { - $scope.users = resp['users']; - $scope.showInterface = true; - }, function(resp) { - $scope.users = []; - $scope.usersError = resp['data']['message'] || resp['data']['error_description']; - }); - }; - - $scope.showChangePassword = function(user) { - $scope.userToChange = user; - $('#changePasswordModal').modal({}); - }; - - $scope.createUser = function() { - $scope.creatingUser = true; - $scope.createdUser = null; - - var errorHandler = ApiService.errorDisplay('Cannot create user', function() { - $scope.creatingUser = false; - $('#createUserModal').modal('hide'); - }); - - ApiService.createInstallUser($scope.newUser, null).then(function(resp) { - $scope.creatingUser = false; - $scope.newUser = {}; - $scope.createdUser = resp; - $scope.loadUsersInternal(); - }, errorHandler) - }; - - $scope.showDeleteUser = function(user) { - if (user.username == UserService.currentUser().username) { - bootbox.dialog({ - "message": 'Cannot delete yourself!', - "title": "Cannot delete user", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - return; - } - - $scope.userToDelete = user; - $('#confirmDeleteUserModal').modal({}); - }; - - $scope.changeUserPassword = function(user) { - $('#changePasswordModal').modal('hide'); - - var params = { - 'username': user.username - }; - - var data = { - 'password': user.password - }; - - ApiService.changeInstallUser(data, params).then(function(resp) { - $scope.loadUsersInternal(); - }, ApiService.errorDisplay('Could not change user')); - }; - - $scope.deleteUser = function(user) { - $('#confirmDeleteUserModal').modal('hide'); - - var params = { - 'username': user.username - }; - - ApiService.deleteInstallUser(null, params).then(function(resp) { - $scope.loadUsersInternal(); - }, ApiService.errorDisplay('Cannot delete user')); - }; - - $scope.sendRecoveryEmail = function(user) { - var params = { - 'username': user.username - }; - - ApiService.sendInstallUserRecoveryEmail(null, params).then(function(resp) { - bootbox.dialog({ - "message": "A recovery email has been sent to " + resp['email'], - "title": "Recovery email sent", - "buttons": { - "close": { - "label": "Close", - "className": "btn-primary" - } - } - }); - - }, ApiService.errorDisplay('Cannot send recovery email')) - }; - - $scope.restartContainer = function() { - $('#restartingContainerModal').modal({ - keyboard: false, - backdrop: 'static' - }); - - ContainerService.restartContainer(function() { - $scope.checkStatus() - }); - }; - - $scope.checkStatus = function() { - ContainerService.checkStatus(function(resp) { - $('#restartingContainerModal').modal('hide'); - $scope.configStatus = resp['status']; - $scope.requiresRestart = resp['requires_restart']; - - if ($scope.configStatus == 'ready') { - $scope.loadUsers(); - } else { - var message = "Installation of this product has not yet been completed." + - "

Please read the " + - "" + - "Setup Guide" - - var title = "Installation Incomplete"; - CoreDialog.fatal(title, message); - } - }); - }; - - // Load the initial status. - $scope.checkStatus(); -} \ No newline at end of file diff --git a/static/js/core-ui.js b/static/js/core-ui.js index ed5e982e5..4f50f0e35 100644 --- a/static/js/core-ui.js +++ b/static/js/core-ui.js @@ -548,6 +548,8 @@ angular.module("core-ui", []) }, controller: function($rootScope, $scope, $element) { $scope.$watch('progress', function(progress) { + if (!progress) { return; } + var index = 0; for (var i = 0; i < progress.length; ++i) { if (progress[i]) { diff --git a/static/js/directives/fallback-src.js b/static/js/directives/fallback-src.js new file mode 100644 index 000000000..8c726f1a0 --- /dev/null +++ b/static/js/directives/fallback-src.js @@ -0,0 +1,14 @@ +/** + * Adds a fallback-src attribute, which is used as the source for an tag if the main + * image fails to load. + */ +angular.module('quay').directive('fallbackSrc', function () { + return { + restrict: 'A', + link: function postLink(scope, element, attributes) { + element.bind('error', function() { + angular.element(this).attr("src", attributes.fallbackSrc); + }); + } + }; +}); \ No newline at end of file diff --git a/static/js/directives/file-present.js b/static/js/directives/file-present.js new file mode 100644 index 000000000..a692ac167 --- /dev/null +++ b/static/js/directives/file-present.js @@ -0,0 +1,18 @@ +/** + * Sets the 'filePresent' value on the scope if a file on the marked exists. + */ +angular.module('quay').directive("filePresent", [function () { + return { + restrict: 'A', + scope: { + 'filePresent': "=" + }, + link: function (scope, element, attributes) { + element.bind("change", function (changeEvent) { + scope.$apply(function() { + scope.filePresent = changeEvent.target.files.length > 0; + }); + }); + } + } +}]); \ No newline at end of file diff --git a/static/js/directives/filters/bytes.js b/static/js/directives/filters/bytes.js new file mode 100644 index 000000000..fe0602d84 --- /dev/null +++ b/static/js/directives/filters/bytes.js @@ -0,0 +1,12 @@ +/** + * Filter which displays bytes with suffixes. + */ +angular.module('quay').filter('bytes', function() { + return function(bytes, precision) { + if (!bytes || isNaN(parseFloat(bytes)) || !isFinite(bytes)) return 'Unknown'; + if (typeof precision === 'undefined') precision = 1; + var units = ['bytes', 'kB', 'MB', 'GB', 'TB', 'PB'], + number = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, Math.floor(number))).toFixed(precision) + ' ' + units[number]; + } +}); \ No newline at end of file diff --git a/static/js/directives/filters/regex.js b/static/js/directives/filters/regex.js new file mode 100644 index 000000000..c64f92612 --- /dev/null +++ b/static/js/directives/filters/regex.js @@ -0,0 +1,23 @@ +/** + * Regular expression filter. + */ +angular.module('quay').filter('regex', function() { + return function(input, regex) { + if (!regex) { return []; } + + try { + var patt = new RegExp(regex); + } catch (ex) { + return []; + } + + var out = []; + for (var i = 0; i < input.length; ++i){ + var m = input[i].match(patt); + if (m && m[0].length == input[i].length) { + out.push(input[i]); + } + } + return out; + }; +}); diff --git a/static/js/directives/filters/reverse.js b/static/js/directives/filters/reverse.js new file mode 100644 index 000000000..5c022ade4 --- /dev/null +++ b/static/js/directives/filters/reverse.js @@ -0,0 +1,8 @@ +/** + * Reversing filter. + */ +angular.module('quay').filter('reverse', function() { + return function(items) { + return items.slice().reverse(); + }; +}); \ No newline at end of file diff --git a/static/js/directives/filters/visible-log-filter.js b/static/js/directives/filters/visible-log-filter.js new file mode 100644 index 000000000..274bfa800 --- /dev/null +++ b/static/js/directives/filters/visible-log-filter.js @@ -0,0 +1,19 @@ +/** + * Filter for hiding logs that don't meet the allowed predicate. + */ +angular.module('quay').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; + }; +}); diff --git a/static/js/directives/focusable-popover-content.js b/static/js/directives/focusable-popover-content.js new file mode 100644 index 000000000..98e8a63e6 --- /dev/null +++ b/static/js/directives/focusable-popover-content.js @@ -0,0 +1,37 @@ +/** + * An element which, when used to display content inside a popover, hide the popover once + * the content loses focus. + */ +angular.module('quay').directive('focusablePopoverContent', ['$timeout', '$popover', function ($timeout, $popover) { + return { + restrict: "A", + link: function (scope, element, attrs) { + $body = $('body'); + var hide = function() { + $body.off('click'); + + if (!scope) { return; } + scope.$apply(function() { + if (!scope || !$scope.$hide) { return; } + scope.$hide(); + }); + }; + + scope.$on('$destroy', function() { + $body.off('click'); + }); + + $timeout(function() { + $body.on('click', function(evt) { + var target = evt.target; + var isPanelMember = $(element).has(target).length > 0 || target == element; + if (!isPanelMember) { + hide(); + } + }); + + $(element).find('input').focus(); + }, 100); + } + }; +}]); \ No newline at end of file diff --git a/static/js/directives/match.js b/static/js/directives/match.js new file mode 100644 index 000000000..07bf436d2 --- /dev/null +++ b/static/js/directives/match.js @@ -0,0 +1,16 @@ +/** + * Adds a 'match' attribute that ensures that a form field's value matches another field's + * value. + */ +angular.module('quay').directive('match', function($parse) { + return { + require: 'ngModel', + link: function(scope, elem, attrs, ctrl) { + scope.$watch(function() { + return $parse(attrs.match)(scope) === ctrl.$modelValue; + }, function(currentValue) { + ctrl.$setValidity('mismatch', currentValue); + }); + } + }; +}); \ No newline at end of file diff --git a/static/js/directives/ng-blur.js b/static/js/directives/ng-blur.js new file mode 100644 index 000000000..334973a72 --- /dev/null +++ b/static/js/directives/ng-blur.js @@ -0,0 +1,7 @@ +angular.module('quay').directive('ngBlur', function() { + return function( scope, elem, attrs ) { + elem.bind('blur', function() { + scope.$apply(attrs.ngBlur); + }); + }; +}); diff --git a/static/js/directives/ng-if-media.js b/static/js/directives/ng-if-media.js new file mode 100644 index 000000000..8348a4297 --- /dev/null +++ b/static/js/directives/ng-if-media.js @@ -0,0 +1,15 @@ +/** + * Adds an ng-if-media attribute that evaluates a media query and, if false, removes the element. + */ +angular.module('quay').directive('ngIfMedia', function ($animate, AngularHelper) { + return { + transclude: 'element', + priority: 600, + terminal: true, + restrict: 'A', + link: AngularHelper.buildConditionalLinker($animate, 'ngIfMedia', function(value) { + return window.matchMedia(value).matches; + }) + }; +}); + diff --git a/static/js/directives/ng-visible.js b/static/js/directives/ng-visible.js new file mode 100644 index 000000000..fc5a0ca4e --- /dev/null +++ b/static/js/directives/ng-visible.js @@ -0,0 +1,10 @@ +/** + * Adds an ng-visible attribute that hides an element if the expression evaluates to false. + */ +angular.module('quay').directive('ngVisible', function () { + return function (scope, element, attr) { + scope.$watch(attr.ngVisible, function (visible) { + element.css('visibility', visible ? 'visible' : 'hidden'); + }); + }; +}); \ No newline at end of file diff --git a/static/js/directives/onresize.js b/static/js/directives/onresize.js new file mode 100644 index 000000000..b45e6e606 --- /dev/null +++ b/static/js/directives/onresize.js @@ -0,0 +1,20 @@ +/** + * Adds an onresize event attribtue that gets invokved when the size of the window changes. + */ +angular.module('quay').directive('onresize', function ($window, $parse) { + return function (scope, element, attr) { + var fn = $parse(attr.onresize); + + var notifyResized = function() { + scope.$apply(function () { + fn(scope); + }); + }; + + angular.element($window).on('resize', null, notifyResized); + + scope.$on('$destroy', function() { + angular.element($window).off('resize', null, notifyResized); + }); + }; +}); \ No newline at end of file diff --git a/static/js/directives/quay-layout.js b/static/js/directives/quay-layout.js new file mode 100644 index 000000000..58db5b450 --- /dev/null +++ b/static/js/directives/quay-layout.js @@ -0,0 +1,170 @@ +/** + * Directives which show, hide, include or otherwise mutate the DOM based on Features and Config. + */ + +/** + * Adds a quay-require attribute includes an element in the DOM iff the features specified are true. + */ +angular.module('quay').directive('quayRequire', function ($animate, Features, AngularHelper) { + return { + transclude: 'element', + priority: 600, + terminal: true, + restrict: 'A', + link: AngularHelper.buildConditionalLinker($animate, 'quayRequire', function(value) { + return Features.matchesFeatures(value); + }) + }; +}); + +/** + * Adds a quay-show attribute that shows the element only if the attribute evaluates to true. + * The Features and Config services are added into the scope's context automatically. + */ +angular.module('quay').directive('quayShow', function($animate, Features, Config) { + return { + priority: 590, + restrict: 'A', + link: function($scope, $element, $attr, ctrl, $transclude) { + $scope.Features = Features; + $scope.Config = Config; + $scope.$watch($attr.quayShow, function(result) { + $animate[!!result ? 'removeClass' : 'addClass']($element, 'ng-hide'); + }); + } + }; +}); + + +/** + * Adds a quay-section attribute that adds an 'active' class to the element if the current URL + * matches the given section. + */ +angular.module('quay').directive('quaySection', function($animate, $location, $rootScope) { + return { + priority: 590, + restrict: 'A', + link: function($scope, $element, $attr, ctrl, $transclude) { + var update = function() { + var result = $location.path().indexOf('/' + $attr.quaySection) == 0; + $animate[!result ? 'removeClass' : 'addClass']($element, 'active'); + }; + + $scope.$watch(function(){ + return $location.path(); + }, update); + + $scope.$watch($attr.quaySection, update); + } + }; +}); + +/** + * Adds a quay-classes attribute that performs like ng-class, but with Features and Config also + * available in the scope automatically. + */ +angular.module('quay').directive('quayClasses', function(Features, Config) { + return { + priority: 580, + restrict: 'A', + link: function($scope, $element, $attr, ctrl, $transclude) { + + // Borrowed from ngClass. + function flattenClasses(classVal) { + if(angular.isArray(classVal)) { + return classVal.join(' '); + } else if (angular.isObject(classVal)) { + var classes = [], i = 0; + angular.forEach(classVal, function(v, k) { + if (v) { + classes.push(k); + } + }); + return classes.join(' '); + } + + return classVal; + } + + function removeClass(classVal) { + $attr.$removeClass(flattenClasses(classVal)); + } + + + function addClass(classVal) { + $attr.$addClass(flattenClasses(classVal)); + } + + $scope.$watch($attr.quayClasses, function(result) { + var scopeVals = { + 'Features': Features, + 'Config': Config + }; + + for (var expr in result) { + if (!result.hasOwnProperty(expr)) { continue; } + + // Evaluate the expression with the entire features list added. + var value = $scope.$eval(expr, scopeVals); + if (value) { + addClass(result[expr]); + } else { + removeClass(result[expr]); + } + } + }); + } + }; +}); + +/** + * Adds a quay-include attribtue that adds a template solely if the expression evaluates to true. + * Automatically adds the Features and Config services to the scope. + */ +angular.module('quay').directive('quayInclude', function($compile, $templateCache, $http, Features, Config) { + return { + priority: 595, + restrict: 'A', + link: function($scope, $element, $attr, ctrl) { + var getTemplate = function(templateName) { + var templateUrl = '/static/partials/' + templateName; + return $http.get(templateUrl, {cache: $templateCache}); + }; + + var result = $scope.$eval($attr.quayInclude); + if (!result) { + return; + } + + var scopeVals = { + 'Features': Features, + 'Config': Config + }; + + var templatePath = null; + for (var expr in result) { + if (!result.hasOwnProperty(expr)) { continue; } + + // Evaluate the expression with the entire features list added. + var value = $scope.$eval(expr, scopeVals); + if (value) { + templatePath = result[expr]; + break; + } + } + + if (!templatePath) { + return; + } + + var promise = getTemplate(templatePath).success(function(html) { + $element.html(html); + }).then(function (response) { + $element.replaceWith($compile($element.html())($scope)); + if ($attr.onload) { + $scope.$eval($attr.onload); + } + }); + } + }; +}); diff --git a/static/js/directives/ui/application-info.js b/static/js/directives/ui/application-info.js new file mode 100644 index 000000000..764b93b68 --- /dev/null +++ b/static/js/directives/ui/application-info.js @@ -0,0 +1,18 @@ +/** + * An element which shows information about a registered OAuth application. + */ +angular.module('quay').directive('applicationInfo', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/application-info.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'application': '=application' + }, + controller: function($scope, $element, ApiService) {} + }; + return directiveDefinitionObject; +}); + diff --git a/static/js/directives/ui/application-manager.js b/static/js/directives/ui/application-manager.js new file mode 100644 index 000000000..910476f2a --- /dev/null +++ b/static/js/directives/ui/application-manager.js @@ -0,0 +1,56 @@ +/** + * Element for managing the applications of an organization. + */ +angular.module('quay').directive('applicationManager', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/application-manager.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'organization': '=organization', + 'makevisible': '=makevisible' + }, + controller: function($scope, $element, ApiService) { + $scope.loading = false; + $scope.applications = []; + + $scope.createApplication = function(appName) { + if (!appName) { return; } + + var params = { + 'orgname': $scope.organization.name + }; + + var data = { + 'name': appName + }; + + ApiService.createOrganizationApplication(data, params).then(function(resp) { + $scope.applications.push(resp); + }, ApiService.errorDisplay('Cannot create application')); + }; + + var update = function() { + if (!$scope.organization || !$scope.makevisible) { return; } + if ($scope.loading) { return; } + + $scope.loading = true; + + var params = { + 'orgname': $scope.organization.name + }; + + ApiService.getOrganizationApplications(null, params).then(function(resp) { + $scope.loading = false; + $scope.applications = resp['applications'] || []; + }); + }; + + $scope.$watch('organization', update); + $scope.$watch('makevisible', update); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/application-reference.js b/static/js/directives/ui/application-reference.js new file mode 100644 index 000000000..19e232903 --- /dev/null +++ b/static/js/directives/ui/application-reference.js @@ -0,0 +1,36 @@ +/** + * An element which shows information about an OAuth application and provides a clickable link + * for displaying a dialog with further information. Unlike application-info, this element is + * intended for the *owner* of the application (since it requires the client ID). + */ +angular.module('quay').directive('applicationReference', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/application-reference.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'title': '=title', + 'clientId': '=clientId' + }, + controller: function($scope, $element, ApiService, $modal) { + $scope.showAppDetails = function() { + var params = { + 'client_id': $scope.clientId + }; + + ApiService.getApplicationInformation(null, params).then(function(resp) { + $scope.applicationInfo = resp; + $modal({ + title: 'Application Information', + scope: $scope, + template: '/static/directives/application-reference-dialog.html', + show: true + }); + }, ApiService.errorDisplay('Application could not be found')); + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/avatar.js b/static/js/directives/ui/avatar.js new file mode 100644 index 000000000..e2e9b339f --- /dev/null +++ b/static/js/directives/ui/avatar.js @@ -0,0 +1,34 @@ +/** + * An element which displays an avatar for the given {email,name} or hash. + */ +angular.module('quay').directive('avatar', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/avatar.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'hash': '=hash', + 'email': '=email', + 'name': '=name', + 'size': '=size' + }, + controller: function($scope, $element, AvatarService) { + $scope.AvatarService = AvatarService; + + var refreshHash = function() { + if (!$scope.name && !$scope.email) { return; } + $scope._hash = AvatarService.computeHash($scope.email, $scope.name); + }; + + $scope.$watch('hash', function(hash) { + $scope._hash = hash; + }); + + $scope.$watch('name', refreshHash); + $scope.$watch('email', refreshHash); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/billing-invoice.js b/static/js/directives/ui/billing-invoice.js new file mode 100644 index 000000000..f7ff123f7 --- /dev/null +++ b/static/js/directives/ui/billing-invoice.js @@ -0,0 +1,49 @@ +/** + * Element for displaying the list of billing invoices for the user or organization. + */ +angular.module('quay').directive('billingInvoices', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/billing-invoices.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'organization': '=organization', + 'user': '=user', + 'makevisible': '=makevisible' + }, + controller: function($scope, $element, $sce, ApiService) { + $scope.loading = false; + $scope.invoiceExpanded = {}; + + $scope.toggleInvoice = function(id) { + $scope.invoiceExpanded[id] = !$scope.invoiceExpanded[id]; + }; + + var update = function() { + var hasValidUser = !!$scope.user; + var hasValidOrg = !!$scope.organization; + var isValid = hasValidUser || hasValidOrg; + + if (!$scope.makevisible || !isValid) { + return; + } + + $scope.loading = true; + + ApiService.listInvoices($scope.organization).then(function(resp) { + $scope.invoices = resp.invoices; + $scope.loading = false; + }); + }; + + $scope.$watch('organization', update); + $scope.$watch('user', update); + $scope.$watch('makevisible', update); + } + }; + + return directiveDefinitionObject; +}); + diff --git a/static/js/directives/ui/billing-options.js b/static/js/directives/ui/billing-options.js new file mode 100644 index 000000000..4400d527b --- /dev/null +++ b/static/js/directives/ui/billing-options.js @@ -0,0 +1,113 @@ +/** + * An element which displays the billing options for a user or an organization. + */ +angular.module('quay').directive('billingOptions', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/billing-options.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'user': '=user', + 'organization': '=organization' + }, + controller: function($scope, $element, PlanService, ApiService) { + $scope.invoice_email = false; + $scope.currentCard = null; + + // Listen to plan changes. + PlanService.registerListener(this, function(plan) { + if (plan && plan.price > 0) { + update(); + } + }); + + $scope.$on('$destroy', function() { + PlanService.unregisterListener(this); + }); + + $scope.isExpiringSoon = function(cardInfo) { + var current = new Date(); + var expires = new Date(cardInfo.exp_year, cardInfo.exp_month, 1); + var difference = expires - current; + return difference < (60 * 60 * 24 * 60 * 1000 /* 60 days */); + }; + + $scope.changeCard = function() { + var previousCard = $scope.currentCard; + $scope.changingCard = true; + var callbacks = { + 'opened': function() { $scope.changingCard = true; }, + 'closed': function() { $scope.changingCard = false; }, + 'started': function() { $scope.currentCard = null; }, + 'success': function(resp) { + $scope.currentCard = resp.card; + $scope.changingCard = false; + }, + 'failure': function(resp) { + $scope.changingCard = false; + $scope.currentCard = previousCard; + + if (!PlanService.isCardError(resp)) { + $('#cannotchangecardModal').modal({}); + } + } + }; + + PlanService.changeCreditCard($scope, $scope.organization ? $scope.organization.name : null, callbacks); + }; + + $scope.getCreditImage = function(creditInfo) { + if (!creditInfo || !creditInfo.type) { return 'credit.png'; } + + var kind = creditInfo.type.toLowerCase() || 'credit'; + var supported = { + 'american express': 'amex', + 'credit': 'credit', + 'diners club': 'diners', + 'discover': 'discover', + 'jcb': 'jcb', + 'mastercard': 'mastercard', + 'visa': 'visa' + }; + + kind = supported[kind] || 'credit'; + return kind + '.png'; + }; + + var update = function() { + if (!$scope.user && !$scope.organization) { return; } + $scope.obj = $scope.user ? $scope.user : $scope.organization; + $scope.invoice_email = $scope.obj.invoice_email; + + // Load the credit card information. + PlanService.getCardInfo($scope.organization ? $scope.organization.name : null, function(card) { + $scope.currentCard = card; + }); + }; + + var save = function() { + $scope.working = true; + + var errorHandler = ApiService.errorDisplay('Could not change user details'); + ApiService.changeDetails($scope.organization, $scope.obj).then(function(resp) { + $scope.working = false; + }, errorHandler); + }; + + var checkSave = function() { + if (!$scope.obj) { return; } + if ($scope.obj.invoice_email != $scope.invoice_email) { + $scope.obj.invoice_email = $scope.invoice_email; + save(); + } + }; + + $scope.$watch('invoice_email', checkSave); + $scope.$watch('organization', update); + $scope.$watch('user', update); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/build-log-command.js b/static/js/directives/ui/build-log-command.js new file mode 100644 index 000000000..68300cda8 --- /dev/null +++ b/static/js/directives/ui/build-log-command.js @@ -0,0 +1,26 @@ +/** + * An element which displays a command in a build. + */ +angular.module('quay').directive('buildLogCommand', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/build-log-command.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'command': '=command' + }, + controller: function($scope, $element) { + $scope.getWithoutStep = function(fullTitle) { + var colon = fullTitle.indexOf(':'); + if (colon <= 0) { + return ''; + } + + return $.trim(fullTitle.substring(colon + 1)); + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/build-log-error.js b/static/js/directives/ui/build-log-error.js new file mode 100644 index 000000000..c748cd597 --- /dev/null +++ b/static/js/directives/ui/build-log-error.js @@ -0,0 +1,62 @@ +/** + * An element which displays a build error in a nice format. + */ +angular.module('quay').directive('buildLogError', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/build-log-error.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'error': '=error', + 'entries': '=entries' + }, + controller: function($scope, $element, Config) { + $scope.isInternalError = function() { + var entry = $scope.entries[$scope.entries.length - 1]; + return entry && entry.data && entry.data['internal_error']; + }; + + $scope.getLocalPullInfo = function() { + if ($scope.entries.__localpull !== undefined) { + return $scope.entries.__localpull; + } + + var localInfo = { + 'isLocal': false + }; + + // Find the 'pulling' phase entry, and then extra any metadata found under + // it. + for (var i = 0; i < $scope.entries.length; ++i) { + var entry = $scope.entries[i]; + if (entry.type == 'phase' && entry.message == 'pulling') { + for (var j = 0; j < entry.logs.length(); ++j) { + var log = entry.logs.get(j); + if (log.data && log.data.phasestep == 'login') { + localInfo['login'] = log.data; + } + + if (log.data && log.data.phasestep == 'pull') { + var repo_url = log.data['repo_url']; + var repo_and_tag = repo_url.substring(Config.SERVER_HOSTNAME.length + 1); + var tagIndex = repo_and_tag.lastIndexOf(':'); + var repo = repo_and_tag.substring(0, tagIndex); + + localInfo['repo_url'] = repo_url; + localInfo['repo'] = repo; + + localInfo['isLocal'] = repo_url.indexOf(Config.SERVER_HOSTNAME + '/') == 0; + } + } + break; + } + } + + return $scope.entries.__localpull = localInfo; + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/build-log-phase.js b/static/js/directives/ui/build-log-phase.js new file mode 100644 index 000000000..7e67e0ca8 --- /dev/null +++ b/static/js/directives/ui/build-log-phase.js @@ -0,0 +1,18 @@ +/** + * An element which displays the phase of a build nicely. + */ +angular.module('quay').directive('buildLogPhase', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/build-log-phase.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'phase': '=phase' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/build-message.js b/static/js/directives/ui/build-message.js new file mode 100644 index 000000000..e0ac208d8 --- /dev/null +++ b/static/js/directives/ui/build-message.js @@ -0,0 +1,61 @@ +/** + * An element which displays a user-friendly message for the current phase of a build. + */ +angular.module('quay').directive('buildMessage', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/build-message.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'phase': '=phase' + }, + controller: function($scope, $element) { + $scope.getBuildMessage = function (phase) { + switch (phase) { + case 'cannot_load': + return 'Cannot load build status - Please report this error'; + + case 'starting': + case 'initializing': + return 'Starting Dockerfile build'; + + case 'waiting': + return 'Waiting for available build worker'; + + case 'unpacking': + return 'Unpacking build package'; + + case 'pulling': + return 'Pulling base image'; + + case 'building': + return 'Building image from Dockerfile'; + + case 'checking-cache': + return 'Looking up cached images'; + + case 'priming-cache': + return 'Priming cache for build'; + + case 'build-scheduled': + return 'Preparing build node'; + + case 'pushing': + return 'Pushing image built from Dockerfile'; + + case 'complete': + return 'Dockerfile build completed and pushed'; + + case 'error': + return 'Dockerfile build failed'; + + case 'internalerror': + return 'An internal system error occurred while building; the build will be retried in the next few minutes.'; + } + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/build-progress.js b/static/js/directives/ui/build-progress.js new file mode 100644 index 000000000..7be3b232e --- /dev/null +++ b/static/js/directives/ui/build-progress.js @@ -0,0 +1,53 @@ +/** + * An element which displays a progressbar for the given build. + */ +angular.module('quay').directive('buildProgress', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/build-progress.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'build': '=build' + }, + controller: function($scope, $element) { + $scope.getPercentage = function(buildInfo) { + switch (buildInfo.phase) { + case 'pulling': + return buildInfo.status.pull_completion * 100; + break; + + case 'building': + return (buildInfo.status.current_command / buildInfo.status.total_commands) * 100; + break; + + case 'pushing': + return buildInfo.status.push_completion * 100; + break; + + case 'priming-cache': + return buildInfo.status.cache_completion * 100; + break; + + case 'complete': + return 100; + break; + + case 'initializing': + case 'checking-cache': + case 'starting': + case 'waiting': + case 'cannot_load': + case 'unpacking': + return 0; + break; + } + + return -1; + }; + } + }; + return directiveDefinitionObject; +}); + diff --git a/static/js/directives/ui/build-status.js b/static/js/directives/ui/build-status.js new file mode 100644 index 000000000..a15af1546 --- /dev/null +++ b/static/js/directives/ui/build-status.js @@ -0,0 +1,18 @@ +/** + * An element which displays the status of a build. + */ +angular.module('quay').directive('buildStatus', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/build-status.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'build': '=build' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/copy-box.js b/static/js/directives/ui/copy-box.js new file mode 100644 index 000000000..7c490221c --- /dev/null +++ b/static/js/directives/ui/copy-box.js @@ -0,0 +1,76 @@ +$.fn.clipboardCopy = function() { + if (__zeroClipboardSupported) { + (new ZeroClipboard($(this))); + return true; + } + + this.hide(); + return false; +}; + +// Initialize the clipboard system. +(function () { + __zeroClipboardSupported = true; + + ZeroClipboard.config({ + 'swfPath': 'static/lib/ZeroClipboard.swf' + }); + + ZeroClipboard.on("error", function(e) { + __zeroClipboardSupported = false; + }); + + ZeroClipboard.on('aftercopy', function(e) { + var container = e.target.parentNode.parentNode.parentNode; + var message = $(container).find('.clipboard-copied-message')[0]; + + // Resets the animation. + var elem = message; + elem.style.display = 'none'; + elem.classList.remove('animated'); + + // Show the notification. + setTimeout(function() { + elem.style.display = 'inline-block'; + elem.classList.add('animated'); + }, 10); + + // Reset the notification. + setTimeout(function() { + elem.style.display = 'none'; + }, 5000); + }); +})(); + +/** + * An element which displays a textfield with a "Copy to Clipboard" icon next to it. Note + * that this method depends on the clipboard copying library in the lib/ folder. + */ +angular.module('quay').directive('copyBox', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/copy-box.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'value': '=value', + 'hoveringMessage': '=hoveringMessage', + }, + controller: function($scope, $element, $rootScope) { + $scope.disabled = false; + + var number = $rootScope.__copyBoxIdCounter || 0; + $rootScope.__copyBoxIdCounter = number + 1; + $scope.inputId = "copy-box-input-" + number; + + var button = $($element).find('.copy-icon'); + var input = $($element).find('input'); + + input.attr('id', $scope.inputId); + button.attr('data-clipboard-target', $scope.inputId); + $scope.disabled = !button.clipboardCopy(); + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/create-external-notification-dialog.js b/static/js/directives/ui/create-external-notification-dialog.js new file mode 100644 index 000000000..7d50eb6c5 --- /dev/null +++ b/static/js/directives/ui/create-external-notification-dialog.js @@ -0,0 +1,144 @@ +/** + * An element which displays a dialog to register a new external notification on a repository. + */ +angular.module('quay').directive('createExternalNotificationDialog', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/create-external-notification-dialog.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repository': '=repository', + 'counter': '=counter', + 'notificationCreated': '¬ificationCreated' + }, + controller: function($scope, $element, ExternalNotificationData, ApiService, $timeout, StringBuilderService) { + $scope.currentEvent = null; + $scope.currentMethod = null; + $scope.status = ''; + $scope.currentConfig = {}; + $scope.clearCounter = 0; + $scope.unauthorizedEmail = false; + + $scope.events = ExternalNotificationData.getSupportedEvents(); + $scope.methods = ExternalNotificationData.getSupportedMethods(); + + $scope.getPattern = function(field) { + return new RegExp(field.regex); + }; + + $scope.setEvent = function(event) { + $scope.currentEvent = event; + }; + + $scope.setMethod = function(method) { + $scope.currentConfig = {}; + $scope.currentMethod = method; + $scope.unauthorizedEmail = false; + }; + + $scope.createNotification = function() { + if (!$scope.currentConfig.email) { + $scope.performCreateNotification(); + return; + } + + $scope.status = 'checking-email'; + $scope.checkEmailAuthorization(); + }; + + $scope.checkEmailAuthorization = function() { + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'email': $scope.currentConfig.email + }; + + ApiService.checkRepoEmailAuthorized(null, params).then(function(resp) { + $scope.handleEmailCheck(resp.confirmed); + }, function(resp) { + $scope.handleEmailCheck(false); + }); + }; + + $scope.performCreateNotification = function() { + $scope.status = 'creating'; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name + }; + + var data = { + 'event': $scope.currentEvent.id, + 'method': $scope.currentMethod.id, + 'config': $scope.currentConfig + }; + + ApiService.createRepoNotification(data, params).then(function(resp) { + $scope.status = ''; + $scope.notificationCreated({'notification': resp}); + $('#createNotificationModal').modal('hide'); + }); + }; + + $scope.handleEmailCheck = function(isAuthorized) { + if (isAuthorized) { + $scope.performCreateNotification(); + return; + } + + if ($scope.status == 'authorizing-email-sent') { + $scope.watchEmail(); + } else { + $scope.status = 'unauthorized-email'; + } + + $scope.unauthorizedEmail = true; + }; + + $scope.sendAuthEmail = function() { + $scope.status = 'authorizing-email'; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'email': $scope.currentConfig.email + }; + + ApiService.sendAuthorizeRepoEmail(null, params).then(function(resp) { + $scope.status = 'authorizing-email-sent'; + $scope.watchEmail(); + }); + }; + + $scope.watchEmail = function() { + // TODO: change this to SSE? + $timeout(function() { + $scope.checkEmailAuthorization(); + }, 1000); + }; + + $scope.getHelpUrl = function(field, config) { + var helpUrl = field['help_url']; + if (!helpUrl) { + return null; + } + + return StringBuilderService.buildUrl(helpUrl, config); + }; + + $scope.$watch('counter', function(counter) { + if (counter) { + $scope.clearCounter++; + $scope.status = ''; + $scope.currentEvent = null; + $scope.currentMethod = null; + $scope.unauthorizedEmail = false; + $('#createNotificationModal').modal({}); + } + }); + } + }; + return directiveDefinitionObject; +}); + + diff --git a/static/js/directives/ui/delete-ui.js b/static/js/directives/ui/delete-ui.js new file mode 100644 index 000000000..6f349ed42 --- /dev/null +++ b/static/js/directives/ui/delete-ui.js @@ -0,0 +1,26 @@ +/** + * A two-step delete button that slides into view when clicked. + */ +angular.module('quay').directive('deleteUi', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/delete-ui.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'deleteTitle': '=deleteTitle', + 'buttonTitle': '=buttonTitle', + 'performDelete': '&performDelete' + }, + controller: function($scope, $element) { + $scope.buttonTitleInternal = $scope.buttonTitle || 'Delete'; + + $element.children().attr('tabindex', 0); + $scope.focus = function() { + $element[0].firstChild.focus(); + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/docker-auth-dialog.js b/static/js/directives/ui/docker-auth-dialog.js new file mode 100644 index 000000000..15a7e752c --- /dev/null +++ b/static/js/directives/ui/docker-auth-dialog.js @@ -0,0 +1,86 @@ +/** + * An element which displays a dialog with docker auth credentials for an entity. + */ +angular.module('quay').directive('dockerAuthDialog', function (Config) { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/docker-auth-dialog.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'username': '=username', + 'token': '=token', + 'shown': '=shown', + 'counter': '=counter', + 'supportsRegenerate': '@supportsRegenerate', + 'regenerate': '®enerate' + }, + controller: function($scope, $element) { + var updateCommand = function() { + var escape = function(v) { + if (!v) { return v; } + return v.replace('$', '\\$'); + }; + $scope.command = 'docker login -e="." -u="' + escape($scope.username) + + '" -p="' + $scope.token + '" ' + Config['SERVER_HOSTNAME']; + }; + + $scope.$watch('username', updateCommand); + $scope.$watch('token', updateCommand); + + $scope.regenerating = true; + + $scope.askRegenerate = function() { + bootbox.confirm('Are you sure you want to regenerate the token? All existing login credentials will become invalid', function(resp) { + if (resp) { + $scope.regenerating = true; + $scope.regenerate({'username': $scope.username, 'token': $scope.token}); + } + }); + }; + + $scope.isDownloadSupported = function() { + var isSafari = /^((?!chrome).)*safari/i.test(navigator.userAgent); + if (isSafari) { + // Doesn't work properly in Safari, sadly. + return false; + } + + try { return !!new Blob(); } catch(e) {} + return false; + }; + + $scope.downloadCfg = function() { + var auth = $.base64.encode($scope.username + ":" + $scope.token); + config = {} + config[Config['SERVER_HOSTNAME']] = { + "auth": auth, + "email": "" + }; + + var file = JSON.stringify(config, null, ' '); + var blob = new Blob([file]); + saveAs(blob, '.dockercfg'); + }; + + var show = function(r) { + $scope.regenerating = false; + + if (!$scope.shown || !$scope.username || !$scope.token) { + $('#dockerauthmodal').modal('hide'); + return; + } + + $('#copyClipboard').clipboardCopy(); + $('#dockerauthmodal').modal({}); + }; + + $scope.$watch('counter', show); + $scope.$watch('shown', show); + $scope.$watch('username', show); + $scope.$watch('token', show); + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/dockerfile-build-dialog.js b/static/js/directives/ui/dockerfile-build-dialog.js new file mode 100644 index 000000000..e8e45214b --- /dev/null +++ b/static/js/directives/ui/dockerfile-build-dialog.js @@ -0,0 +1,45 @@ +/** + * An element which displays a dialog for manually starting a dockerfile build. + */ +angular.module('quay').directive('dockerfileBuildDialog', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/dockerfile-build-dialog.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repository': '=repository', + 'showNow': '=showNow', + 'buildStarted': '&buildStarted' + }, + controller: function($scope, $element) { + $scope.building = false; + $scope.uploading = false; + $scope.startCounter = 0; + + $scope.handleBuildStarted = function(build) { + $('#dockerfilebuildModal').modal('hide'); + if ($scope.buildStarted) { + $scope.buildStarted({'build': build}); + } + }; + + $scope.handleBuildFailed = function(message) { + $scope.errorMessage = message; + }; + + $scope.startBuild = function() { + $scope.errorMessage = null; + $scope.startCounter++; + }; + + $scope.$watch('showNow', function(sn) { + if (sn && $scope.repository) { + $('#dockerfilebuildModal').modal({}); + } + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/dockerfile-build-form.js b/static/js/directives/ui/dockerfile-build-form.js new file mode 100644 index 000000000..b62fba979 --- /dev/null +++ b/static/js/directives/ui/dockerfile-build-form.js @@ -0,0 +1,199 @@ +/** + * An element which displays a form for manually starting a dockerfile build. + */ +angular.module('quay').directive('dockerfileBuildForm', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/dockerfile-build-form.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repository': '=repository', + 'startNow': '=startNow', + 'isReady': '=isReady', + 'uploadFailed': '&uploadFailed', + 'uploadStarted': '&uploadStarted', + 'buildStarted': '&buildStarted', + 'buildFailed': '&buildFailed', + 'missingFile': '&missingFile', + 'uploading': '=uploading', + 'building': '=building' + }, + controller: function($scope, $element, ApiService) { + $scope.internal = {'hasDockerfile': false}; + $scope.pull_entity = null; + $scope.is_public = true; + + var handleBuildFailed = function(message) { + message = message || 'Dockerfile build failed to start'; + + var result = false; + if ($scope.buildFailed) { + result = $scope.buildFailed({'message': message}); + } + + if (!result) { + bootbox.dialog({ + "message": message, + "title": "Cannot start Dockerfile build", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + } + }; + + var handleUploadFailed = function(message) { + message = message || 'Error with file upload'; + + var result = false; + if ($scope.uploadFailed) { + result = $scope.uploadFailed({'message': message}); + } + + if (!result) { + bootbox.dialog({ + "message": message, + "title": "Cannot upload file for Dockerfile build", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + } + }; + + var handleMissingFile = function() { + var result = false; + if ($scope.missingFile) { + result = $scope.missingFile({}); + } + + if (!result) { + bootbox.dialog({ + "message": 'A Dockerfile or an archive containing a Dockerfile is required', + "title": "Missing Dockerfile", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + } + }; + + var startBuild = function(fileId) { + $scope.building = true; + + var repo = $scope.repository; + var data = { + 'file_id': fileId + }; + + if (!$scope.is_public && $scope.pull_entity) { + data['pull_robot'] = $scope.pull_entity['name']; + } + + var params = { + 'repository': repo.namespace + '/' + repo.name, + }; + + ApiService.requestRepoBuild(data, params).then(function(resp) { + $scope.building = false; + $scope.uploading = false; + + if ($scope.buildStarted) { + $scope.buildStarted({'build': resp}); + } + }, function(resp) { + $scope.building = false; + $scope.uploading = false; + + handleBuildFailed(resp.message); + }); + }; + + var conductUpload = function(file, url, fileId, mimeType) { + if ($scope.uploadStarted) { + $scope.uploadStarted({}); + } + + var request = new XMLHttpRequest(); + request.open('PUT', url, true); + request.setRequestHeader('Content-Type', mimeType); + request.onprogress = function(e) { + $scope.$apply(function() { + var percentLoaded; + if (e.lengthComputable) { + $scope.upload_progress = (e.loaded / e.total) * 100; + } + }); + }; + request.onerror = function() { + $scope.$apply(function() { + handleUploadFailed(); + }); + }; + request.onreadystatechange = function() { + var state = request.readyState; + if (state == 4) { + $scope.$apply(function() { + startBuild(fileId); + $scope.uploading = false; + }); + return; + } + }; + request.send(file); + }; + + var startFileUpload = function(repo) { + $scope.uploading = true; + $scope.uploading_progress = 0; + + var uploader = $('#file-drop')[0]; + if (uploader.files.length == 0) { + handleMissingFile(); + $scope.uploading = false; + return; + } + + var file = uploader.files[0]; + $scope.upload_file = file.name; + + var mimeType = file.type || 'application/octet-stream'; + var data = { + 'mimeType': mimeType + }; + + var getUploadUrl = ApiService.getFiledropUrl(data).then(function(resp) { + conductUpload(file, resp.url, resp.file_id, mimeType); + }, function() { + handleUploadFailed('Could not retrieve upload URL'); + }); + }; + + var checkIsReady = function() { + $scope.isReady = $scope.internal.hasDockerfile && ($scope.is_public || $scope.pull_entity); + }; + + $scope.$watch('pull_entity', checkIsReady); + $scope.$watch('is_public', checkIsReady); + $scope.$watch('internal.hasDockerfile', checkIsReady); + + $scope.$watch('startNow', function() { + if ($scope.startNow && $scope.repository && !$scope.uploading && !$scope.building) { + startFileUpload(); + } + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/dockerfile-command.js b/static/js/directives/ui/dockerfile-command.js new file mode 100644 index 000000000..c83d53d6e --- /dev/null +++ b/static/js/directives/ui/dockerfile-command.js @@ -0,0 +1,68 @@ +/** + * An element which displays a Dockerfile command nicely formatted, with optional link to the + * image (for FROM commands that link to us or to the DockerHub). + */ +angular.module('quay').directive('dockerfileCommand', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/dockerfile-command.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'command': '=command' + }, + controller: function($scope, $element, UtilService, Config) { + var registryHandlers = { + 'quay.io': function(pieces) { + var rnamespace = pieces[pieces.length - 2]; + var rname = pieces[pieces.length - 1].split(':')[0]; + return '/repository/' + rnamespace + '/' + rname + '/'; + }, + + '': function(pieces) { + var rnamespace = pieces.length == 1 ? '_' : 'u/' + pieces[0]; + var rname = pieces[pieces.length - 1].split(':')[0]; + return 'https://registry.hub.docker.com/' + rnamespace + '/' + rname + '/'; + } + }; + + registryHandlers[Config.getDomain()] = registryHandlers['quay.io']; + + var kindHandlers = { + 'FROM': function(title) { + var pieces = title.split('/'); + var registry = pieces.length < 3 ? '' : pieces[0]; + if (!registryHandlers[registry]) { + return title; + } + + return ' ' + title + ''; + } + }; + + $scope.getCommandKind = function(title) { + var space = title.indexOf(' '); + return title.substring(0, space); + }; + + $scope.getCommandTitleHtml = function(title) { + var space = title.indexOf(' '); + if (space <= 0) { + return UtilService.textToSafeHtml(title); + } + + var kind = $scope.getCommandKind(title); + var sanitized = UtilService.textToSafeHtml(title.substring(space + 1)); + + var handler = kindHandlers[kind || '']; + if (handler) { + return handler(sanitized); + } else { + return sanitized; + } + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/dockerfile-view.js b/static/js/directives/ui/dockerfile-view.js new file mode 100644 index 000000000..d391c8d23 --- /dev/null +++ b/static/js/directives/ui/dockerfile-view.js @@ -0,0 +1,38 @@ +/** + * An element which displays the contents of a Dockerfile in a nicely formatted way. + */ +angular.module('quay').directive('dockerfileView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/dockerfile-view.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'contents': '=contents' + }, + controller: function($scope, $element, UtilService) { + $scope.$watch('contents', function(contents) { + $scope.lines = []; + + var lines = contents ? contents.split('\n') : []; + for (var i = 0; i < lines.length; ++i) { + var line = $.trim(lines[i]); + var kind = 'text'; + if (line && line[0] == '#') { + kind = 'comment'; + } else if (line.match(/^([A-Z]+\s)/)) { + kind = 'command'; + } + + var lineInfo = { + 'text': line, + 'kind': kind + }; + $scope.lines.push(lineInfo); + } + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/dropdown-select.js b/static/js/directives/ui/dropdown-select.js new file mode 100644 index 000000000..2ca86478e --- /dev/null +++ b/static/js/directives/ui/dropdown-select.js @@ -0,0 +1,174 @@ +/** + * An element which displays a dropdown select box which is (optionally) editable. This box + * is displayed with an and a menu on the right. + */ +angular.module('quay').directive('dropdownSelect', function ($compile) { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/dropdown-select.html', + replace: true, + transclude: true, + restrict: 'C', + scope: { + 'selectedItem': '=selectedItem', + 'placeholder': '=placeholder', + 'lookaheadItems': '=lookaheadItems', + + 'allowCustomInput': '@allowCustomInput', + + 'handleItemSelected': '&handleItemSelected', + 'handleInput': '&handleInput', + + 'clearValue': '=clearValue' + }, + controller: function($scope, $element, $rootScope) { + if (!$rootScope.__dropdownSelectCounter) { + $rootScope.__dropdownSelectCounter = 1; + } + + $scope.placeholder = $scope.placeholder || ''; + $scope.internalItem = null; + + // Setup lookahead. + var input = $($element).find('.lookahead-input'); + + $scope.$watch('clearValue', function(cv) { + if (cv) { + $scope.selectedItem = null; + $(input).val(''); + } + }); + + $scope.$watch('selectedItem', function(item) { + if ($scope.selectedItem == $scope.internalItem) { + // The item has already been set due to an internal action. + return; + } + + if ($scope.selectedItem != null) { + $(input).val(item.toString()); + } else { + $(input).val(''); + } + }); + + $scope.$watch('lookaheadItems', function(items) { + $(input).off(); + if (!items) { + return; + } + + var formattedItems = []; + for (var i = 0; i < items.length; ++i) { + var formattedItem = items[i]; + if (typeof formattedItem == 'string') { + formattedItem = { + 'value': formattedItem + }; + } + formattedItems.push(formattedItem); + } + + var dropdownHound = new Bloodhound({ + name: 'dropdown-items-' + $rootScope.__dropdownSelectCounter, + local: formattedItems, + datumTokenizer: function(d) { + return Bloodhound.tokenizers.whitespace(d.val || d.value || ''); + }, + queryTokenizer: Bloodhound.tokenizers.whitespace + }); + dropdownHound.initialize(); + + $(input).typeahead({}, { + source: dropdownHound.ttAdapter(), + templates: { + 'suggestion': function (datum) { + template = datum['template'] ? datum['template'](datum) : datum['value']; + return template; + } + } + }); + + $(input).on('input', function(e) { + $scope.$apply(function() { + $scope.internalItem = null; + $scope.selectedItem = null; + if ($scope.handleInput) { + $scope.handleInput({'input': $(input).val()}); + } + }); + }); + + $(input).on('typeahead:selected', function(e, datum) { + $scope.$apply(function() { + $scope.internalItem = datum['item'] || datum['value']; + $scope.selectedItem = datum['item'] || datum['value']; + if ($scope.handleItemSelected) { + $scope.handleItemSelected({'datum': datum}); + } + }); + }); + + $rootScope.__dropdownSelectCounter++; + }); + }, + link: function(scope, element, attrs) { + var transcludedBlock = element.find('div.transcluded'); + var transcludedElements = transcludedBlock.children(); + + var iconContainer = element.find('div.dropdown-select-icon-transclude'); + var menuContainer = element.find('div.dropdown-select-menu-transclude'); + + angular.forEach(transcludedElements, function(elem) { + if (angular.element(elem).hasClass('dropdown-select-icon')) { + iconContainer.append(elem); + } else if (angular.element(elem).hasClass('dropdown-select-menu')) { + menuContainer.replaceWith(elem); + } + }); + + transcludedBlock.remove(); + } + }; + return directiveDefinitionObject; +}); + + +/** + * An icon in the dropdown select. Only one icon will be displayed at a time. + */ +angular.module('quay').directive('dropdownSelectIcon', function () { + var directiveDefinitionObject = { + priority: 1, + require: '^dropdownSelect', + templateUrl: '/static/directives/dropdown-select-icon.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); + + +/** + * The menu for the dropdown select. + */ +angular.module('quay').directive('dropdownSelectMenu', function () { + var directiveDefinitionObject = { + priority: 1, + require: '^dropdownSelect', + templateUrl: '/static/directives/dropdown-select-menu.html', + replace: true, + transclude: true, + restrict: 'C', + scope: { + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/entity-reference.js b/static/js/directives/ui/entity-reference.js new file mode 100644 index 000000000..41b280304 --- /dev/null +++ b/static/js/directives/ui/entity-reference.js @@ -0,0 +1,56 @@ +/** + * An element which shows an icon and a name/title for an entity (user, org, robot, team), + * optionally linking to that entity if applicable. + */ +angular.module('quay').directive('entityReference', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/entity-reference.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'entity': '=entity', + 'namespace': '=namespace', + 'showAvatar': '@showAvatar', + 'avatarSize': '@avatarSize' + }, + controller: function($scope, $element, UserService, UtilService) { + $scope.getIsAdmin = function(namespace) { + return UserService.isNamespaceAdmin(namespace); + }; + + $scope.getRobotUrl = function(name) { + var namespace = $scope.getPrefix(name); + if (!namespace) { + return ''; + } + + if (!$scope.getIsAdmin(namespace)) { + return ''; + } + + var org = UserService.getOrganization(namespace); + if (!org) { + // This robot is owned by the user. + return '/user/?tab=robots&showRobot=' + UtilService.textToSafeHtml(name); + } + + return '/organization/' + org['name'] + '/admin?tab=robots&showRobot=' + UtilService.textToSafeHtml(name); + }; + + $scope.getPrefix = function(name) { + if (!name) { return ''; } + var plus = name.indexOf('+'); + return name.substr(0, plus); + }; + + $scope.getShortenedName = function(name) { + if (!name) { return ''; } + var plus = name.indexOf('+'); + return name.substr(plus + 1); + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/entity-search.js b/static/js/directives/ui/entity-search.js new file mode 100644 index 000000000..eb7313509 --- /dev/null +++ b/static/js/directives/ui/entity-search.js @@ -0,0 +1,361 @@ + +/** + * An element which displays a box to search for an entity (org, user, robot, team). This control + * allows for filtering of the entities found and whether to allow selection by e-mail. + */ +angular.module('quay').directive('entitySearch', function () { + var number = 0; + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/entity-search.html', + replace: false, + transclude: false, + restrict: 'C', + require: '?ngModel', + link: function(scope, element, attr, ctrl) { + scope.ngModel = ctrl; + }, + scope: { + 'namespace': '=namespace', + 'placeholder': '=placeholder', + + // Default: ['user', 'team', 'robot'] + 'allowedEntities': '=allowedEntities', + + 'currentEntity': '=currentEntity', + + 'entitySelected': '&entitySelected', + 'emailSelected': '&emailSelected', + + // When set to true, the contents of the control will be cleared as soon + // as an entity is selected. + 'autoClear': '=autoClear', + + // Set this property to immediately clear the contents of the control. + 'clearValue': '=clearValue', + + // Whether e-mail addresses are allowed. + 'allowEmails': '=allowEmails', + 'emailMessage': '@emailMessage', + + // True if the menu should pull right. + 'pullRight': '@pullRight' + }, + controller: function($rootScope, $scope, $element, Restangular, UserService, ApiService, UtilService, Config, CreateService) { + $scope.lazyLoading = true; + + $scope.teams = null; + $scope.robots = null; + + $scope.isAdmin = false; + $scope.isOrganization = false; + + $scope.includeTeams = true; + $scope.includeRobots = true; + $scope.includeOrgs = false; + + $scope.currentEntityInternal = $scope.currentEntity; + + var isSupported = function(kind, opt_array) { + return $.inArray(kind, opt_array || $scope.allowedEntities || ['user', 'team', 'robot']) >= 0; + }; + + $scope.lazyLoad = function() { + if (!$scope.namespace || !$scope.lazyLoading) { return; } + + // Reset the cached teams and robots. + $scope.teams = null; + $scope.robots = null; + + // Load the organization's teams (if applicable). + if ($scope.isOrganization && isSupported('team')) { + // Note: We load the org here again so that we always have the fully up-to-date + // teams list. + ApiService.getOrganization(null, {'orgname': $scope.namespace}).then(function(resp) { + $scope.teams = resp.teams; + }); + } + + // Load the user/organization's robots (if applicable). + if ($scope.isAdmin && isSupported('robot')) { + ApiService.getRobots($scope.isOrganization ? $scope.namespace : null).then(function(resp) { + $scope.robots = resp.robots; + $scope.lazyLoading = false; + }, function() { + $scope.lazyLoading = false; + }); + } else { + $scope.lazyLoading = false; + } + }; + + $scope.createTeam = function() { + if (!$scope.isAdmin) { return; } + + bootbox.prompt('Enter the name of the new team', function(teamname) { + if (!teamname) { return; } + + var regex = new RegExp(TEAM_PATTERN); + if (!regex.test(teamname)) { + bootbox.alert('Invalid team name'); + return; + } + + CreateService.createOrganizationTeam(ApiService, $scope.namespace, teamname, function(created) { + $scope.setEntity(created.name, 'team', false); + $scope.teams[teamname] = created; + }); + }); + }; + + $scope.createRobot = function() { + if (!$scope.isAdmin) { return; } + + bootbox.prompt('Enter the name of the new robot account', function(robotname) { + if (!robotname) { return; } + + var regex = new RegExp(ROBOT_PATTERN); + if (!regex.test(robotname)) { + bootbox.alert('Invalid robot account name'); + return; + } + + CreateService.createRobotAccount(ApiService, $scope.isOrganization, $scope.namespace, robotname, function(created) { + $scope.setEntity(created.name, 'user', true); + $scope.robots.push(created); + }); + }); + }; + + $scope.setEntity = function(name, kind, is_robot) { + var entity = { + 'name': name, + 'kind': kind, + 'is_robot': is_robot + }; + + if ($scope.isOrganization) { + entity['is_org_member'] = true; + } + + $scope.setEntityInternal(entity, false); + }; + + $scope.clearEntityInternal = function() { + $scope.currentEntityInternal = null; + $scope.currentEntity = null; + $scope.entitySelected({'entity': null}); + if ($scope.ngModel) { + $scope.ngModel.$setValidity('entity', false); + } + }; + + $scope.setEntityInternal = function(entity, updateTypeahead) { + if (updateTypeahead) { + $(input).typeahead('val', $scope.autoClear ? '' : entity.name); + } else { + $(input).val($scope.autoClear ? '' : entity.name); + } + + if (!$scope.autoClear) { + $scope.currentEntityInternal = entity; + $scope.currentEntity = entity; + } + + $scope.entitySelected({'entity': entity}); + if ($scope.ngModel) { + $scope.ngModel.$setValidity('entity', !!entity); + } + }; + + // Setup the typeahead. + var input = $element[0].firstChild.firstChild; + + (function() { + // Create the bloodhound search query system. + $rootScope.__entity_search_counter = (($rootScope.__entity_search_counter || 0) + 1); + var entitySearchB = new Bloodhound({ + name: 'entities' + $rootScope.__entity_search_counter, + remote: { + url: '/api/v1/entities/%QUERY', + replace: function (url, uriEncodedQuery) { + var namespace = $scope.namespace || ''; + url = url.replace('%QUERY', uriEncodedQuery); + url += '?namespace=' + encodeURIComponent(namespace); + if ($scope.isOrganization && isSupported('team')) { + url += '&includeTeams=true' + } + if (isSupported('org')) { + url += '&includeOrgs=true' + } + return url; + }, + filter: function(data) { + var datums = []; + for (var i = 0; i < data.results.length; ++i) { + var entity = data.results[i]; + + var found = 'user'; + if (entity.kind == 'user') { + found = entity.is_robot ? 'robot' : 'user'; + } else if (entity.kind == 'team') { + found = 'team'; + } else if (entity.kind == 'org') { + found = 'org'; + } + + if (!isSupported(found)) { + continue; + } + + datums.push({ + 'value': entity.name, + 'tokens': [entity.name], + 'entity': entity + }); + } + return datums; + } + }, + datumTokenizer: function(d) { + return Bloodhound.tokenizers.whitespace(d.val); + }, + queryTokenizer: Bloodhound.tokenizers.whitespace + }); + entitySearchB.initialize(); + + // Setup the typeahead. + $(input).typeahead({ + 'highlight': true + }, { + source: entitySearchB.ttAdapter(), + templates: { + 'empty': function(info) { + // Only display the empty dialog if the server load has finished. + if (info.resultKind == 'remote') { + var val = $(input).val(); + if (!val) { + return null; + } + + if (UtilService.isEmailAddress(val)) { + if ($scope.allowEmails) { + return '
' + $scope.emailMessage + '
'; + } else { + return '
A ' + Config.REGISTRY_TITLE_SHORT + ' username (not an e-mail address) must be specified
'; + } + } + + var classes = []; + + if (isSupported('user')) { classes.push('users'); } + if (isSupported('org')) { classes.push('organizations'); } + if ($scope.isAdmin && isSupported('robot')) { classes.push('robot accounts'); } + if ($scope.isOrganization && isSupported('team')) { classes.push('teams'); } + + if (classes.length > 1) { + classes[classes.length - 1] = 'or ' + classes[classes.length - 1]; + } else if (classes.length == 0) { + return '
No matching entities found
'; + } + + var class_string = ''; + for (var i = 0; i < classes.length; ++i) { + if (i > 0) { + if (i == classes.length - 1) { + class_string += ' or '; + } else { + class_string += ', '; + } + } + + class_string += classes[i]; + } + + return '
No matching ' + Config.REGISTRY_TITLE_SHORT + ' ' + class_string + ' found
'; + } + + return null; + }, + 'suggestion': function (datum) { + template = '
'; + if (datum.entity.kind == 'user' && !datum.entity.is_robot) { + template += ''; + } else if (datum.entity.kind == 'user' && datum.entity.is_robot) { + template += ''; + } else if (datum.entity.kind == 'team') { + template += ''; + } else if (datum.entity.kind == 'org') { + template += '' + AvatarService.getAvatar(datum.entity.avatar, 16) + ''; + } + + template += '' + datum.value + ''; + + if (datum.entity.is_org_member === false && datum.entity.kind == 'user') { + template += ''; + } + + template += '
'; + return template; + }} + }); + + $(input).on('keypress', function(e) { + var val = $(input).val(); + var code = e.keyCode || e.which; + if (code == 13 && $scope.allowEmails && UtilService.isEmailAddress(val)) { + $scope.$apply(function() { + $scope.emailSelected({'email': val}); + }); + } + }); + + $(input).on('input', function(e) { + $scope.$apply(function() { + $scope.clearEntityInternal(); + }); + }); + + $(input).on('typeahead:selected', function(e, datum) { + $scope.$apply(function() { + $scope.setEntityInternal(datum.entity, true); + }); + }); + })(); + + $scope.$watch('clearValue', function() { + if (!input) { return; } + + $(input).typeahead('val', ''); + $scope.clearEntityInternal(); + }); + + $scope.$watch('placeholder', function(title) { + input.setAttribute('placeholder', title); + }); + + $scope.$watch('allowedEntities', function(allowed) { + if (!allowed) { return; } + $scope.includeTeams = isSupported('team', allowed); + $scope.includeRobots = isSupported('robot', allowed); + }); + + $scope.$watch('namespace', function(namespace) { + if (!namespace) { return; } + $scope.isAdmin = UserService.isNamespaceAdmin(namespace); + $scope.isOrganization = !!UserService.getOrganization(namespace); + }); + + $scope.$watch('currentEntity', function(entity) { + if ($scope.currentEntityInternal != entity) { + if (entity) { + $scope.setEntityInternal(entity, false); + } else { + $scope.clearEntityInternal(); + } + } + }); + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/external-login-button.js b/static/js/directives/ui/external-login-button.js new file mode 100644 index 000000000..c6733bb38 --- /dev/null +++ b/static/js/directives/ui/external-login-button.js @@ -0,0 +1,40 @@ +/** + * An element which displays a button for logging into the application via an external service. + */ +angular.module('quay').directive('externalLoginButton', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/external-login-button.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'signInStarted': '&signInStarted', + 'redirectUrl': '=redirectUrl', + 'provider': '@provider', + 'action': '@action' + }, + controller: function($scope, $timeout, $interval, ApiService, KeyService, CookieService, Features, Config) { + $scope.signingIn = false; + $scope.isEnterprise = KeyService.isEnterprise; + + $scope.startSignin = function(service) { + $scope.signInStarted({'service': service}); + + var url = KeyService.getExternalLoginUrl(service, $scope.action || 'login'); + + // Save the redirect URL in a cookie so that we can redirect back after the service returns to us. + var redirectURL = $scope.redirectUrl || window.location.toString(); + CookieService.putPermanent('quay.redirectAfterLoad', redirectURL); + + // Needed to ensure that UI work done by the started callback is finished before the location + // changes. + $scope.signingIn = true; + $timeout(function() { + document.location = url; + }, 250); + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/external-notification-view.js b/static/js/directives/ui/external-notification-view.js new file mode 100644 index 000000000..0e7fb4791 --- /dev/null +++ b/static/js/directives/ui/external-notification-view.js @@ -0,0 +1,59 @@ +/** + * An element which displays controls and information about a defined external notification on + * a repository. + */ +angular.module('quay').directive('externalNotificationView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/external-notification-view.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repository': '=repository', + 'notification': '=notification', + 'notificationDeleted': '¬ificationDeleted' + }, + controller: function($scope, $element, ExternalNotificationData, ApiService) { + $scope.deleteNotification = function() { + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'uuid': $scope.notification.uuid + }; + + ApiService.deleteRepoNotification(null, params).then(function() { + $scope.notificationDeleted({'notification': $scope.notification}); + }); + }; + + $scope.testNotification = function() { + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'uuid': $scope.notification.uuid + }; + + ApiService.testRepoNotification(null, params).then(function() { + bootbox.dialog({ + "title": "Test Notification Queued", + "message": "A test version of this notification has been queued and should appear shortly", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }); + }; + + $scope.$watch('notification', function(notification) { + if (notification) { + $scope.eventInfo = ExternalNotificationData.getEventInfo(notification.event); + $scope.methodInfo = ExternalNotificationData.getMethodInfo(notification.method); + $scope.config = notification.config; + } + }); + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/header-bar.js b/static/js/directives/ui/header-bar.js new file mode 100644 index 000000000..e370b9432 --- /dev/null +++ b/static/js/directives/ui/header-bar.js @@ -0,0 +1,45 @@ + +/** + * The application header bar. + */ +angular.module('quay').directive('headerBar', function () { + var number = 0; + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/header-bar.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + }, + controller: function($scope, $element, $location, UserService, PlanService, ApiService, NotificationService, Config) { + $scope.notificationService = NotificationService; + + // Monitor any user changes and place the current user into the scope. + UserService.updateUserIn($scope); + + $scope.signout = function() { + ApiService.logout().then(function() { + UserService.load(); + $location.path('/'); + }); + }; + + $scope.appLinkTarget = function() { + if ($("div[ng-view]").length === 0) { + return "_self"; + } + return ""; + }; + + $scope.getEnterpriseLogo = function() { + if (!Config.ENTERPRISE_LOGO_URL) { + return '/static/img/quay-logo.png'; + } + + return Config.ENTERPRISE_LOGO_URL; + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/location-view.js b/static/js/directives/ui/location-view.js new file mode 100644 index 000000000..d2a043624 --- /dev/null +++ b/static/js/directives/ui/location-view.js @@ -0,0 +1,106 @@ +/** + * An element which displays a small flag representing the given location, as well as a ping + * latency gauge for that location. + */ +angular.module('quay').directive('locationView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/location-view.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'location': '=location' + }, + controller: function($rootScope, $scope, $element, $http, PingService) { + var LOCATIONS = { + 'local_us': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States' }, + 'local_eu': { 'country': 'EU', 'data': 'quay-registry-eu.s3-eu-west-1.amazonaws.com', 'title': 'Europe' }, + + 's3_us_east_1': { 'country': 'US', 'data': 'quay-registry.s3.amazonaws.com', 'title': 'United States (East)' }, + 's3_us_west_1': { 'country': 'US', 'data': 'quay-registry-cali.s3.amazonaws.com', 'title': 'United States (West)' }, + + 's3_eu_west_1': { 'country': 'EU', 'data': 'quay-registry-eu.s3-eu-west-1.amazonaws.com', 'title': 'Europe' }, + + 's3_ap_southeast_1': { 'country': 'SG', 'data': 'quay-registry-singapore.s3-ap-southeast-1.amazonaws.com', 'title': 'Singapore' }, + 's3_ap_southeast_2': { 'country': 'AU', 'data': 'quay-registry-sydney.s3-ap-southeast-2.amazonaws.com', 'title': 'Australia' }, + + // 's3_ap_northeast-1': { 'country': 'JP', 'data': 's3-ap-northeast-1.amazonaws.com', 'title': 'Japan' }, + // 's3_sa_east1': { 'country': 'BR', 'data': 's3-east-1.amazonaws.com', 'title': 'Sao Paulo' } + }; + + $scope.locationPing = null; + $scope.locationPingClass = null; + + $scope.getLocationTooltip = function(location, ping) { + var tip = $scope.getLocationTitle(location) + '
'; + if (ping == null) { + tip += '(Loading)'; + } else if (ping < 0) { + tip += '
Note: Could not contact server'; + } else { + tip += 'Estimated Ping: ' + (ping ? ping + 'ms' : '(Loading)'); + } + return tip; + }; + + $scope.getLocationTitle = function(location) { + if (!LOCATIONS[location]) { + return '(Unknown)'; + } + return 'Image data is located in ' + LOCATIONS[location]['title']; + }; + + $scope.getLocationImage = function(location) { + if (!LOCATIONS[location]) { + return 'unknown.png'; + } + return LOCATIONS[location]['country'] + '.png'; + }; + + $scope.getLocationPing = function(location) { + var url = 'https://' + LOCATIONS[location]['data'] + '/okay.txt'; + PingService.pingUrl($scope, url, function(ping, success, count) { + if (count == 3 || !success) { + $scope.locationPing = success ? ping : -1; + } + }); + }; + + $scope.$watch('location', function(location) { + if (!location) { return; } + $scope.getLocationPing(location); + }); + + $scope.$watch('locationPing', function(locationPing) { + if (locationPing == null) { + $scope.locationPingClass = null; + return; + } + + if (locationPing < 0) { + $scope.locationPingClass = 'error'; + return; + } + + if (locationPing < 100) { + $scope.locationPingClass = 'good'; + return; + } + + if (locationPing < 250) { + $scope.locationPingClass = 'fair'; + return; + } + + if (locationPing < 500) { + $scope.locationPingClass = 'barely'; + return; + } + + $scope.locationPingClass = 'poor'; + }); + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/logs-view.js b/static/js/directives/ui/logs-view.js new file mode 100644 index 000000000..8f85c3261 --- /dev/null +++ b/static/js/directives/ui/logs-view.js @@ -0,0 +1,336 @@ +/** + * Element which displays usage logs for the given entity. + */ +angular.module('quay').directive('logsView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/logs-view.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'organization': '=organization', + 'user': '=user', + 'makevisible': '=makevisible', + 'repository': '=repository', + 'performer': '=performer', + 'allLogs': '@allLogs' + }, + controller: function($scope, $element, $sce, Restangular, ApiService, TriggerService, + StringBuilderService, ExternalNotificationData, UtilService) { + $scope.loading = true; + $scope.logs = null; + $scope.kindsAllowed = null; + $scope.chartVisible = true; + $scope.logsPath = ''; + + var datetime = new Date(); + $scope.logStartDate = new Date(datetime.getUTCFullYear(), datetime.getUTCMonth(), datetime.getUTCDate() - 7); + $scope.logEndDate = new Date(datetime.getUTCFullYear(), datetime.getUTCMonth(), datetime.getUTCDate()); + + var defaultPermSuffix = function(metadata) { + if (metadata.activating_username) { + return ', when creating user is {activating_username}'; + } + return ''; + }; + + var logDescriptions = { + '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: {robot}', + 'delete_robot': 'Delete Robot Account: {robot}', + 'create_repo': 'Create Repository: {repo}', + 'push_repo': 'Push to repository: {repo}', + 'repo_verb': function(metadata) { + var prefix = ''; + if (metadata.verb == 'squash') { + prefix = 'Pull of squashed tag {tag}' + } + + if (metadata.token) { + if (metadata.token_type == 'build-worker') { + prefix += ' by build worker'; + } else { + prefix += ' via token'; + } + } else if (metadata.username) { + prefix += ' by {username}'; + } else { + prefix += ' by {_ip}'; + } + + return prefix; + }, + 'pull_repo': function(metadata) { + if (metadata.token) { + var prefix = 'Pull of repository' + if (metadata.token_type == 'build-worker') { + prefix += ' by build worker'; + } else { + prefix += ' via token'; + } + return prefix; + } else if (metadata.username) { + return 'Pull repository {repo} by {username}'; + } else { + return 'Public pull of repository {repo} by {_ip}'; + } + }, + 'delete_repo': 'Delete repository: {repo}', + 'change_repo_permission': function(metadata) { + if (metadata.username) { + return 'Change permission for user {username} in repository {repo} to {role}'; + } else if (metadata.team) { + return 'Change permission for team {team} in repository {repo} to {role}'; + } else if (metadata.token) { + return 'Change permission for token {token} in repository {repo} to {role}'; + } + }, + 'delete_repo_permission': function(metadata) { + if (metadata.username) { + return 'Remove permission for user {username} from repository {repo}'; + } else if (metadata.team) { + return 'Remove permission for team {team} from repository {repo}'; + } else if (metadata.token) { + return 'Remove permission for token {token} from repository {repo}'; + } + }, + 'delete_tag': 'Tag {tag} deleted in repository {repo} by user {username}', + 'create_tag': 'Tag {tag} created in repository {repo} on image {image} by user {username}', + 'move_tag': 'Tag {tag} moved from image {original_image} to image {image} in repository {repo} by user {username}', + 'change_repo_visibility': 'Change visibility for repository {repo} to {visibility}', + 'add_repo_accesstoken': 'Create access token {token} in repository {repo}', + 'delete_repo_accesstoken': 'Delete access token {token} in repository {repo}', + 'set_repo_description': 'Change description for repository {repo}: {description}', + 'build_dockerfile': function(metadata) { + if (metadata.trigger_id) { + var triggerDescription = TriggerService.getDescription( + metadata['service'], metadata['config']); + return 'Build image from Dockerfile for repository {repo} triggered by ' + triggerDescription; + } + return 'Build image from Dockerfile for repository {repo}'; + }, + 'org_create_team': 'Create team: {team}', + 'org_delete_team': 'Delete team: {team}', + 'org_add_team_member': 'Add member {member} to team {team}', + 'org_remove_team_member': 'Remove member {member} from team {team}', + 'org_invite_team_member': function(metadata) { + if (metadata.user) { + return 'Invite {user} to team {team}'; + } else { + return 'Invite {email} to team {team}'; + } + }, + 'org_delete_team_member_invite': function(metadata) { + if (metadata.user) { + return 'Rescind invite of {user} to team {team}'; + } else { + return 'Rescind invite of {email} to team {team}'; + } + }, + + 'org_team_member_invite_accepted': 'User {member}, invited by {inviter}, joined team {team}', + 'org_team_member_invite_declined': 'User {member}, invited by {inviter}, declined to join team {team}', + + 'org_set_team_description': 'Change description of team {team}: {description}', + 'org_set_team_role': 'Change permission of team {team} to {role}', + 'create_prototype_permission': function(metadata) { + if (metadata.delegate_user) { + return 'Create default permission: {role} for {delegate_user}' + defaultPermSuffix(metadata); + } else if (metadata.delegate_team) { + return 'Create default permission: {role} for {delegate_team}' + defaultPermSuffix(metadata); + } + }, + 'modify_prototype_permission': function(metadata) { + if (metadata.delegate_user) { + return 'Modify default permission: {role} (from {original_role}) for {delegate_user}' + defaultPermSuffix(metadata); + } else if (metadata.delegate_team) { + return 'Modify default permission: {role} (from {original_role}) for {delegate_team}' + defaultPermSuffix(metadata); + } + }, + 'delete_prototype_permission': function(metadata) { + if (metadata.delegate_user) { + return 'Delete default permission: {role} for {delegate_user}' + defaultPermSuffix(metadata); + } else if (metadata.delegate_team) { + return 'Delete default permission: {role} for {delegate_team}' + defaultPermSuffix(metadata); + } + }, + 'setup_repo_trigger': function(metadata) { + var triggerDescription = TriggerService.getDescription( + metadata['service'], metadata['config']); + return 'Setup build trigger - ' + triggerDescription; + }, + 'delete_repo_trigger': function(metadata) { + var triggerDescription = TriggerService.getDescription( + metadata['service'], metadata['config']); + return 'Delete build trigger - ' + triggerDescription; + }, + 'create_application': 'Create application {application_name} with client ID {client_id}', + 'update_application': 'Update application to {application_name} for client ID {client_id}', + 'delete_application': 'Delete application {application_name} with client ID {client_id}', + 'reset_application_client_secret': 'Reset the Client Secret of application {application_name} ' + + 'with client ID {client_id}', + + 'add_repo_notification': function(metadata) { + var eventData = ExternalNotificationData.getEventInfo(metadata.event); + return 'Add notification of event "' + eventData['title'] + '" for repository {repo}'; + }, + + 'delete_repo_notification': function(metadata) { + var eventData = ExternalNotificationData.getEventInfo(metadata.event); + return 'Delete notification of event "' + eventData['title'] + '" for repository {repo}'; + }, + + 'regenerate_robot_token': 'Regenerated token for robot {robot}', + + // Note: These are deprecated. + 'add_repo_webhook': 'Add webhook in repository {repo}', + 'delete_repo_webhook': 'Delete webhook in repository {repo}' + }; + + 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', + 'repo_verb': 'Pull Repo Verb', + 'pull_repo': 'Pull repository', + 'delete_repo': 'Delete 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', + 'set_repo_description': 'Change repository description', + 'build_dockerfile': 'Build image from Dockerfile', + 'delete_tag': 'Delete Tag', + 'create_tag': 'Create Tag', + 'move_tag': 'Move Tag', + 'org_create_team': 'Create team', + 'org_delete_team': 'Delete team', + 'org_add_team_member': 'Add team member', + 'org_invite_team_member': 'Invite team member', + 'org_delete_team_member_invite': 'Rescind team member invitation', + 'org_remove_team_member': 'Remove team member', + 'org_team_member_invite_accepted': 'Team invite accepted', + 'org_team_member_invite_declined': 'Team invite declined', + 'org_set_team_description': 'Change team description', + 'org_set_team_role': 'Change team permission', + 'create_prototype_permission': 'Create default permission', + 'modify_prototype_permission': 'Modify default permission', + 'delete_prototype_permission': 'Delete default permission', + 'setup_repo_trigger': 'Setup build trigger', + 'delete_repo_trigger': 'Delete build trigger', + 'create_application': 'Create Application', + 'update_application': 'Update Application', + 'delete_application': 'Delete Application', + 'reset_application_client_secret': 'Reset Client Secret', + 'add_repo_notification': 'Add repository notification', + 'delete_repo_notification': 'Delete repository notification', + 'regenerate_robot_token': 'Regenerate Robot Token', + + // Note: these are deprecated. + 'add_repo_webhook': 'Add webhook', + 'delete_repo_webhook': 'Delete webhook' + }; + + var getDateString = function(date) { + return (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear(); + }; + + var getOffsetDate = function(date, days) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); + }; + + var update = function() { + var hasValidUser = !!$scope.user; + var hasValidOrg = !!$scope.organization; + var hasValidRepo = $scope.repository && $scope.repository.namespace; + var isValid = hasValidUser || hasValidOrg || hasValidRepo || $scope.allLogs; + + if (!$scope.makevisible || !isValid) { + return; + } + + var twoWeeksAgo = getOffsetDate($scope.logEndDate, -14); + if ($scope.logStartDate > $scope.logEndDate || $scope.logStartDate < twoWeeksAgo) { + $scope.logStartDate = twoWeeksAgo; + } + + $scope.loading = true; + + // Note: We construct the URLs here manually because we also use it for the download + // path. + var url = UtilService.getRestUrl('user/logs'); + if ($scope.organization) { + url = UtilService.getRestUrl('organization', $scope.organization.name, 'logs'); + } + if ($scope.repository) { + url = UtilService.getRestUrl('repository', $scope.repository.namespace, $scope.repository.name, 'logs'); + } + + if ($scope.allLogs) { + url = UtilService.getRestUrl('superuser', 'logs') + } + + url += '?starttime=' + encodeURIComponent(getDateString($scope.logStartDate)); + url += '&endtime=' + encodeURIComponent(getDateString($scope.logEndDate)); + + if ($scope.performer) { + url += '&performer=' + encodeURIComponent($scope.performer.name); + } + + var loadLogs = Restangular.one(url); + loadLogs.customGET().then(function(resp) { + $scope.logsPath = '/api/v1/' + url; + + if (!$scope.chart) { + $scope.chart = new LogUsageChart(logKinds); + $($scope.chart).bind('filteringChanged', function(e) { + $scope.$apply(function() { $scope.kindsAllowed = e.allowed; }); + }); + } + + $scope.chart.draw('bar-chart', resp.logs, $scope.logStartDate, $scope.logEndDate); + $scope.kindsAllowed = null; + $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) { + log.metadata['_ip'] = log.ip ? log.ip : null; + return StringBuilderService.buildString(logDescriptions[log.kind] || log.kind, log.metadata); + }; + + $scope.$watch('organization', update); + $scope.$watch('user', update); + $scope.$watch('repository', update); + $scope.$watch('makevisible', update); + $scope.$watch('performer', update); + $scope.$watch('logStartDate', update); + $scope.$watch('logEndDate', update); + } + }; + + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/manual-trigger-build-dialog.js b/static/js/directives/ui/manual-trigger-build-dialog.js new file mode 100644 index 000000000..617588e46 --- /dev/null +++ b/static/js/directives/ui/manual-trigger-build-dialog.js @@ -0,0 +1,61 @@ +/** + * An element which displays a dialog for manually trigger a build. + */ +angular.module('quay').directive('manualTriggerBuildDialog', function () { + var directiveDefinitionObject = { + templateUrl: '/static/directives/manual-trigger-build-dialog.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repository': '=repository', + 'counter': '=counter', + 'trigger': '=trigger', + 'startBuild': '&startBuild' + }, + controller: function($scope, $element, ApiService, TriggerService) { + $scope.parameters = {}; + $scope.fieldOptions = {}; + + $scope.startTrigger = function() { + $('#startTriggerDialog').modal('hide'); + $scope.startBuild({ + 'trigger': $scope.trigger, + 'parameters': $scope.parameters + }); + }; + + $scope.show = function() { + $scope.parameters = {}; + $scope.fieldOptions = {}; + + var parameters = TriggerService.getRunParameters($scope.trigger.service); + for (var i = 0; i < parameters.length; ++i) { + var parameter = parameters[i]; + if (parameter['type'] == 'option') { + // Load the values for this parameter. + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger.id, + 'field_name': parameter['name'] + }; + + ApiService.listTriggerFieldValues(null, params).then(function(resp) { + $scope.fieldOptions[parameter['name']] = resp['values']; + }); + } + } + $scope.runParameters = parameters; + + $('#startTriggerDialog').modal('show'); + }; + + $scope.$watch('counter', function(counter) { + if (counter) { + $scope.show(); + } + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/markdown-input.js b/static/js/directives/ui/markdown-input.js new file mode 100644 index 000000000..aad6b1a27 --- /dev/null +++ b/static/js/directives/ui/markdown-input.js @@ -0,0 +1,49 @@ +/** + * An element which allows for entry of markdown content and previewing its rendering. + */ +angular.module('quay').directive('markdownInput', function () { + var counter = 0; + + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/markdown-input.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'content': '=content', + 'canWrite': '=canWrite', + 'contentChanged': '=contentChanged', + 'fieldTitle': '=fieldTitle' + }, + controller: function($scope, $element) { + var elm = $element[0]; + + $scope.id = (counter++); + + $scope.editContent = function() { + if (!$scope.canWrite) { return; } + + if (!$scope.markdownDescriptionEditor) { + var converter = Markdown.getSanitizingConverter(); + var editor = new Markdown.Editor(converter, '-description-' + $scope.id); + editor.run(); + $scope.markdownDescriptionEditor = editor; + } + + $('#wmd-input-description-' + $scope.id)[0].value = $scope.content; + $(elm).find('.modal').modal({}); + }; + + $scope.saveContent = function() { + $scope.content = $('#wmd-input-description-' + $scope.id)[0].value; + $(elm).find('.modal').modal('hide'); + + if ($scope.contentChanged) { + $scope.contentChanged($scope.content); + } + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/markdown-view.js b/static/js/directives/ui/markdown-view.js new file mode 100644 index 000000000..65f123f33 --- /dev/null +++ b/static/js/directives/ui/markdown-view.js @@ -0,0 +1,25 @@ +/** + * An element which displays its content processed as markdown. + */ +angular.module('quay').directive('markdownView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/markdown-view.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'content': '=content', + 'firstLineOnly': '=firstLineOnly' + }, + controller: function($scope, $element, $sce, UtilService) { + $scope.getMarkedDown = function(content, firstLineOnly) { + if (firstLineOnly) { + return $sce.trustAsHtml(UtilService.getFirstMarkdownLineAsText(content)); + } + return $sce.trustAsHtml(UtilService.getMarkedDown(content)); + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/namespace-selector.js b/static/js/directives/ui/namespace-selector.js new file mode 100644 index 000000000..bd73cc74c --- /dev/null +++ b/static/js/directives/ui/namespace-selector.js @@ -0,0 +1,74 @@ +/** + * An element which displays a dropdown namespace selector or, if there is only a single namespace, + * that namespace. + */ +angular.module('quay').directive('namespaceSelector', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/namespace-selector.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'user': '=user', + 'namespace': '=namespace', + 'requireCreate': '=requireCreate' + }, + controller: function($scope, $element, $routeParams, $location, CookieService) { + $scope.namespaces = {}; + + $scope.initialize = function(user) { + var preferredNamespace = user.username; + var namespaces = {}; + namespaces[user.username] = user; + if (user.organizations) { + for (var i = 0; i < user.organizations.length; ++i) { + namespaces[user.organizations[i].name] = user.organizations[i]; + if (user.organizations[i].preferred_namespace) { + preferredNamespace = user.organizations[i].name; + } + } + } + + var initialNamespace = $routeParams['namespace'] || CookieService.get('quay.namespace') || + preferredNamespace || $scope.user.username; + $scope.namespaces = namespaces; + $scope.setNamespace($scope.namespaces[initialNamespace]); + }; + + $scope.setNamespace = function(namespaceObj) { + if (!namespaceObj) { + namespaceObj = $scope.namespaces[$scope.user.username]; + } + + if ($scope.requireCreate && !namespaceObj.can_create_repo) { + namespaceObj = $scope.namespaces[$scope.user.username]; + } + + var newNamespace = namespaceObj.name || namespaceObj.username; + $scope.namespaceObj = namespaceObj; + $scope.namespace = newNamespace; + + if (newNamespace) { + CookieService.putPermanent('quay.namespace', newNamespace); + + if ($routeParams['namespace'] && $routeParams['namespace'] != newNamespace) { + $location.search({'namespace': newNamespace}); + } + } + }; + + $scope.$watch('namespace', function(namespace) { + if ($scope.namespaceObj && namespace && namespace != $scope.namespaceObj.username) { + $scope.setNamespace($scope.namespaces[namespace]); + } + }); + + $scope.$watch('user', function(user) { + $scope.user = user; + $scope.initialize(user); + }); + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/notification-view.js b/static/js/directives/ui/notification-view.js new file mode 100644 index 000000000..4c00b15ae --- /dev/null +++ b/static/js/directives/ui/notification-view.js @@ -0,0 +1,69 @@ +/** + * An element which displays an application notification's information. + */ +angular.module('quay').directive('notificationView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/notification-view.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'notification': '=notification', + 'parent': '=parent' + }, + controller: function($scope, $element, $window, $location, UserService, NotificationService, ApiService) { + var stringStartsWith = function (str, prefix) { + return str.slice(0, prefix.length) == prefix; + }; + + $scope.getMessage = function(notification) { + return NotificationService.getMessage(notification); + }; + + $scope.getAvatar = function(orgname) { + var organization = UserService.getOrganization(orgname); + return organization['avatar'] || ''; + }; + + $scope.parseDate = function(dateString) { + return Date.parse(dateString); + }; + + $scope.showNotification = function() { + var url = NotificationService.getPage($scope.notification); + if (url) { + if (stringStartsWith(url, 'http://') || stringStartsWith(url, 'https://')) { + $window.location.href = url; + } else { + var parts = url.split('?') + $location.path(parts[0]); + + if (parts.length > 1) { + $location.search(parts[1]); + } + + $scope.parent.$hide(); + } + } + }; + + $scope.dismissNotification = function(notification) { + NotificationService.dismissNotification(notification); + }; + + $scope.canDismiss = function(notification) { + return NotificationService.canDismiss(notification); + }; + + $scope.getClass = function(notification) { + return NotificationService.getClass(notification); + }; + + $scope.getActions = function(notification) { + return NotificationService.getActions(notification); + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/notifications-bubble.js b/static/js/directives/ui/notifications-bubble.js new file mode 100644 index 000000000..13864c5af --- /dev/null +++ b/static/js/directives/ui/notifications-bubble.js @@ -0,0 +1,19 @@ +/** + * An element which displays the number and kind of application notifications. If there are no + * notifications, then the element is hidden/empty. + */ +angular.module('quay').directive('notificationsBubble', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/notifications-bubble.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + }, + controller: function($scope, UserService, NotificationService) { + $scope.notificationService = NotificationService; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/organization-header.js b/static/js/directives/ui/organization-header.js new file mode 100644 index 000000000..543778f7f --- /dev/null +++ b/static/js/directives/ui/organization-header.js @@ -0,0 +1,20 @@ +/** + * An element which displays an organization header, optionally with trancluded content. + */ +angular.module('quay').directive('organizationHeader', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/organization-header.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'organization': '=organization', + 'teamName': '=teamName', + 'clickable': '=clickable' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/plan-manager.js b/static/js/directives/ui/plan-manager.js new file mode 100644 index 000000000..72816b134 --- /dev/null +++ b/static/js/directives/ui/plan-manager.js @@ -0,0 +1,112 @@ +/** + * Element for managing subscriptions. + */ +angular.module('quay').directive('planManager', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/plan-manager.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'user': '=user', + 'organization': '=organization', + 'readyForPlan': '&readyForPlan', + 'planChanged': '&planChanged' + }, + controller: function($scope, $element, PlanService, ApiService) { + $scope.isExistingCustomer = false; + + $scope.parseDate = function(timestamp) { + return new Date(timestamp * 1000); + }; + + $scope.isPlanVisible = function(plan, subscribedPlan) { + if (plan['deprecated']) { + return plan == subscribedPlan; + } + + if ($scope.organization && !PlanService.isOrgCompatible(plan)) { + return false; + } + + return true; + }; + + $scope.changeSubscription = function(planId, opt_async) { + if ($scope.planChanging) { return; } + + var callbacks = { + 'opening': function() { $scope.planChanging = true; }, + 'started': function() { $scope.planChanging = true; }, + 'opened': function() { $scope.planChanging = true; }, + 'closed': function() { $scope.planChanging = false; }, + 'success': subscribedToPlan, + 'failure': function(resp) { + $scope.planChanging = false; + } + }; + + PlanService.changePlan($scope, $scope.organization, planId, callbacks, opt_async); + }; + + $scope.cancelSubscription = function() { + $scope.changeSubscription(PlanService.getFreePlan()); + }; + + var subscribedToPlan = function(sub) { + $scope.subscription = sub; + $scope.isExistingCustomer = !!sub['isExistingCustomer']; + + PlanService.getPlanIncludingDeprecated(sub.plan, function(subscribedPlan) { + $scope.subscribedPlan = subscribedPlan; + $scope.planUsagePercent = sub.usedPrivateRepos * 100 / $scope.subscribedPlan.privateRepos; + + if ($scope.planChanged) { + $scope.planChanged({ 'plan': subscribedPlan }); + } + + $scope.planChanging = false; + $scope.planLoading = false; + }); + }; + + var update = function() { + $scope.planLoading = true; + if (!$scope.plans) { return; } + + PlanService.getSubscription($scope.organization, subscribedToPlan, function() { + $scope.isExistingCustomer = false; + subscribedToPlan({ 'plan': PlanService.getFreePlan() }); + }); + }; + + var loadPlans = function() { + if ($scope.plans || $scope.loadingPlans) { return; } + if (!$scope.user && !$scope.organization) { return; } + + $scope.loadingPlans = true; + PlanService.verifyLoaded(function(plans) { + $scope.plans = plans; + update(); + + if ($scope.readyForPlan) { + var planRequested = $scope.readyForPlan(); + if (planRequested && planRequested != PlanService.getFreePlan()) { + $scope.changeSubscription(planRequested, /* async */true); + } + } + }); + }; + + // Start the initial download. + $scope.planLoading = true; + loadPlans(); + + $scope.$watch('organization', loadPlans); + $scope.$watch('user', loadPlans); + } + }; + return directiveDefinitionObject; +}); + diff --git a/static/js/directives/ui/plans-table.js b/static/js/directives/ui/plans-table.js new file mode 100644 index 000000000..53953cadc --- /dev/null +++ b/static/js/directives/ui/plans-table.js @@ -0,0 +1,23 @@ +/** + * An element which shows a table of all the defined subscription plans and allows one to be + * highlighted. + */ +angular.module('quay').directive('plansTable', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/plans-table.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'plans': '=plans', + 'currentPlan': '=currentPlan' + }, + controller: function($scope, $element) { + $scope.setPlan = function(plan) { + $scope.currentPlan = plan; + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/popup-input-button.js b/static/js/directives/ui/popup-input-button.js new file mode 100644 index 000000000..688fd2203 --- /dev/null +++ b/static/js/directives/ui/popup-input-button.js @@ -0,0 +1,48 @@ +/** + * An element which, when clicked, displays a popup input dialog to accept a text value. + */ +angular.module('quay').directive('popupInputButton', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/popup-input-button.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'placeholder': '=placeholder', + 'pattern': '=pattern', + 'submitted': '&submitted' + }, + controller: function($scope, $element) { + $scope.popupShown = function() { + setTimeout(function() { + var box = $('#input-box'); + box[0].value = ''; + box.focus(); + }, 40); + }; + + $scope.getRegexp = function(pattern) { + if (!pattern) { + pattern = '.*'; + } + return new RegExp(pattern); + }; + + $scope.inputSubmit = function() { + var box = $('#input-box'); + if (box.hasClass('ng-invalid')) { return; } + + var entered = box[0].value; + if (!entered) { + return; + } + + if ($scope.submitted) { + $scope.submitted({'value': entered}); + } + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/prototype-manager.js b/static/js/directives/ui/prototype-manager.js new file mode 100644 index 000000000..13f03fd00 --- /dev/null +++ b/static/js/directives/ui/prototype-manager.js @@ -0,0 +1,123 @@ +/** + * Element for managing the prototype permissions for an organization. + */ +angular.module('quay').directive('prototypeManager', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/prototype-manager.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'organization': '=organization' + }, + controller: function($scope, $element, ApiService) { + $scope.loading = false; + $scope.activatingForNew = null; + $scope.delegateForNew = null; + $scope.clearCounter = 0; + $scope.newForWholeOrg = true; + + $scope.roles = [ + { 'id': 'read', 'title': 'Read', 'kind': 'success' }, + { 'id': 'write', 'title': 'Write', 'kind': 'success' }, + { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } + ]; + + $scope.setRole = function(role, prototype) { + var params = { + 'orgname': $scope.organization.name, + 'prototypeid': prototype.id + }; + + var data = { + 'id': prototype.id, + 'role': role + }; + + ApiService.updateOrganizationPrototypePermission(data, params).then(function(resp) { + prototype.role = role; + }, ApiService.errorDisplay('Cannot modify permission')); + }; + + $scope.comparePrototypes = function(p) { + return p.activating_user ? p.activating_user.name : ' '; + }; + + $scope.setRoleForNew = function(role) { + $scope.newRole = role; + }; + + $scope.setNewForWholeOrg = function(value) { + $scope.newForWholeOrg = value; + }; + + $scope.showAddDialog = function() { + $scope.activatingForNew = null; + $scope.delegateForNew = null; + $scope.newRole = 'read'; + $scope.clearCounter++; + $scope.newForWholeOrg = true; + $('#addPermissionDialogModal').modal({}); + }; + + $scope.createPrototype = function() { + $scope.loading = true; + + var params = { + 'orgname': $scope.organization.name + }; + + var data = { + 'delegate': $scope.delegateForNew, + 'role': $scope.newRole + }; + + if (!$scope.newForWholeOrg) { + data['activating_user'] = $scope.activatingForNew; + } + + var errorHandler = ApiService.errorDisplay('Cannot create permission', + function(resp) { + $('#addPermissionDialogModal').modal('hide'); + }); + + ApiService.createOrganizationPrototypePermission(data, params).then(function(resp) { + $scope.prototypes.push(resp); + $scope.loading = false; + $('#addPermissionDialogModal').modal('hide'); + }, errorHandler); + }; + + $scope.deletePrototype = function(prototype) { + $scope.loading = true; + + var params = { + 'orgname': $scope.organization.name, + 'prototypeid': prototype.id + }; + + ApiService.deleteOrganizationPrototypePermission(null, params).then(function(resp) { + $scope.prototypes.splice($scope.prototypes.indexOf(prototype), 1); + $scope.loading = false; + }, ApiService.errorDisplay('Cannot delete permission')); + }; + + var update = function() { + if (!$scope.organization) { return; } + if ($scope.loading) { return; } + + var params = {'orgname': $scope.organization.name}; + + $scope.loading = true; + ApiService.getOrganizationPrototypePermissions(null, params).then(function(resp) { + $scope.prototypes = resp.prototypes; + $scope.loading = false; + }); + }; + + $scope.$watch('organization', update); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/ps-usage-graph.js b/static/js/directives/ui/ps-usage-graph.js new file mode 100644 index 000000000..7e1629b34 --- /dev/null +++ b/static/js/directives/ui/ps-usage-graph.js @@ -0,0 +1,50 @@ +/** + * An element which displays charts and graphs representing the current installation of the + * application. This control requires superuser access and *must be disabled when not visible*. + */ +angular.module('quay').directive('psUsageGraph', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/ps-usage-graph.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'isEnabled': '=isEnabled' + }, + controller: function($scope, $element) { + $scope.counter = -1; + $scope.data = null; + + var source = null; + + var connect = function() { + if (source) { return; } + source = new EventSource('/realtime/ps'); + source.onmessage = function(e) { + $scope.$apply(function() { + $scope.counter++; + $scope.data = JSON.parse(e.data); + }); + }; + }; + + var disconnect = function() { + if (!source) { return; } + source.close(); + source = null; + }; + + $scope.$watch('isEnabled', function(value) { + if (value) { + connect(); + } else { + disconnect(); + } + }); + + $scope.$on("$destroy", disconnect); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/quay-spinner.js b/static/js/directives/ui/quay-spinner.js new file mode 100644 index 000000000..faa4a25cd --- /dev/null +++ b/static/js/directives/ui/quay-spinner.js @@ -0,0 +1,16 @@ +/** + * A spinner. + */ +angular.module('quay').directive('quaySpinner', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/spinner.html', + replace: false, + transclude: true, + restrict: 'C', + scope: {}, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/registry-name.js b/static/js/directives/ui/registry-name.js new file mode 100644 index 000000000..b37123607 --- /dev/null +++ b/static/js/directives/ui/registry-name.js @@ -0,0 +1,20 @@ +/** + * An element which displays the name of the registry (optionally the short name). + */ +angular.module('quay').directive('registryName', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/registry-name.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'isShort': '=isShort' + }, + controller: function($scope, $element, Config) { + $scope.name = $scope.isShort ? Config.REGISTRY_TITLE_SHORT : Config.REGISTRY_TITLE; + } + }; + return directiveDefinitionObject; +}); + diff --git a/static/js/directives/ui/repo-breadcrumb.js b/static/js/directives/ui/repo-breadcrumb.js new file mode 100644 index 000000000..4377a16c6 --- /dev/null +++ b/static/js/directives/ui/repo-breadcrumb.js @@ -0,0 +1,22 @@ +/** + * An element which shows the breadcrumbs for a repository, including subsections such as an + * an image or a generic subsection. + */ +angular.module('quay').directive('repoBreadcrumb', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/repo-breadcrumb.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repo': '=repo', + 'image': '=image', + 'subsection': '=subsection', + 'subsectionIcon': '=subsectionIcon' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/repo-circle.js b/static/js/directives/ui/repo-circle.js new file mode 100644 index 000000000..767a00390 --- /dev/null +++ b/static/js/directives/ui/repo-circle.js @@ -0,0 +1,18 @@ +/** + * An element which shows a repository icon (inside a circle). + */ +angular.module('quay').directive('repoCircle', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/repo-circle.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repo': '=repo' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/repo-list-grid.js b/static/js/directives/ui/repo-list-grid.js similarity index 67% rename from static/js/directives/repo-list-grid.js rename to static/js/directives/ui/repo-list-grid.js index 3f936e6f5..ae4d8ab81 100644 --- a/static/js/directives/repo-list-grid.js +++ b/static/js/directives/ui/repo-list-grid.js @@ -1,4 +1,8 @@ -quayApp.directive('repoListGrid', function() { +/** + * An element that displays a list of repositories in a grid. + * + */ +angular.module('quay').directive('repoListGrid', function() { return { templateUrl: '/static/directives/repo-list-grid.html', priority: 0, diff --git a/static/js/directives/ui/repo-search.js b/static/js/directives/ui/repo-search.js new file mode 100644 index 000000000..d46764404 --- /dev/null +++ b/static/js/directives/ui/repo-search.js @@ -0,0 +1,76 @@ +/** + * An element which displays a repository search box. + */ +angular.module('quay').directive('repoSearch', function () { + var number = 0; + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/repo-search.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + }, + controller: function($scope, $element, $location, UserService, Restangular, UtilService) { + var searchToken = 0; + $scope.$watch( function () { return UserService.currentUser(); }, function (currentUser) { + ++searchToken; + }, true); + + var repoHound = new Bloodhound({ + name: 'repositories', + remote: { + url: '/api/v1/find/repository?query=%QUERY', + replace: function (url, uriEncodedQuery) { + url = url.replace('%QUERY', uriEncodedQuery); + url += '&cb=' + searchToken; + return url; + }, + filter: function(data) { + var datums = []; + for (var i = 0; i < data.repositories.length; ++i) { + var repo = data.repositories[i]; + datums.push({ + 'value': repo.name, + 'tokens': [repo.name, repo.namespace], + 'repo': repo + }); + } + return datums; + } + }, + datumTokenizer: function(d) { + return Bloodhound.tokenizers.whitespace(d.val); + }, + queryTokenizer: Bloodhound.tokenizers.whitespace + }); + repoHound.initialize(); + + var element = $($element[0].childNodes[0]); + element.typeahead({ 'highlight': true }, { + source: repoHound.ttAdapter(), + templates: { + 'suggestion': function (datum) { + template = '
'; + template += '' + template += '' + datum.repo.namespace +'/' + datum.repo.name + '' + if (datum.repo.description) { + template += '' + UtilService.getFirstMarkdownLineAsText(datum.repo.description) + '' + } + + template += '
' + return template; + } + } + }); + + element.on('typeahead:selected', function (e, datum) { + element.typeahead('val', ''); + $scope.$apply(function() { + $location.path('/repository/' + datum.repo.namespace + '/' + datum.repo.name); + }); + }); + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/resource-view.js b/static/js/directives/ui/resource-view.js new file mode 100644 index 000000000..a0e71c0e0 --- /dev/null +++ b/static/js/directives/ui/resource-view.js @@ -0,0 +1,20 @@ +/** + * An element which displays either a resource (if present) or an error message if the resource + * failed to load. + */ +angular.module('quay').directive('resourceView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/resource-view.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'resource': '=resource', + 'errorMessage': '=errorMessage' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/robots-manager.js b/static/js/directives/ui/robots-manager.js new file mode 100644 index 000000000..8ebf04337 --- /dev/null +++ b/static/js/directives/ui/robots-manager.js @@ -0,0 +1,102 @@ +/** + * Element for managing the robots owned by an organization or a user. + */ +angular.module('quay').directive('robotsManager', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/robots-manager.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'organization': '=organization', + 'user': '=user' + }, + controller: function($scope, $element, ApiService, $routeParams, CreateService) { + $scope.ROBOT_PATTERN = ROBOT_PATTERN; + $scope.robots = null; + $scope.loading = false; + $scope.shownRobot = null; + $scope.showRobotCounter = 0; + + $scope.regenerateToken = function(username) { + if (!username) { return; } + + var shortName = $scope.getShortenedName(username); + ApiService.regenerateRobotToken($scope.organization, null, {'robot_shortname': shortName}).then(function(updated) { + var index = $scope.findRobotIndexByName(username); + if (index >= 0) { + $scope.robots.splice(index, 1); + $scope.robots.push(updated); + } + $scope.shownRobot = updated; + }, ApiService.errorDisplay('Cannot regenerate robot account token')); + }; + + $scope.showRobot = function(info) { + $scope.shownRobot = info; + $scope.showRobotCounter++; + }; + + $scope.findRobotIndexByName = function(name) { + for (var i = 0; i < $scope.robots.length; ++i) { + if ($scope.robots[i].name == name) { + return i; + } + } + return -1; + }; + + $scope.getShortenedName = function(name) { + var plus = name.indexOf('+'); + return name.substr(plus + 1); + }; + + $scope.getPrefix = function(name) { + var plus = name.indexOf('+'); + return name.substr(0, plus); + }; + + $scope.createRobot = function(name) { + if (!name) { return; } + + CreateService.createRobotAccount(ApiService, !!$scope.organization, $scope.organization ? $scope.organization.name : '', name, + function(created) { + $scope.robots.push(created); + }); + }; + + $scope.deleteRobot = function(info) { + var shortName = $scope.getShortenedName(info.name); + ApiService.deleteRobot($scope.organization, null, {'robot_shortname': shortName}).then(function(resp) { + var index = $scope.findRobotIndexByName(info.name); + if (index >= 0) { + $scope.robots.splice(index, 1); + } + }, ApiService.errorDisplay('Cannot delete robot account')); + }; + + var update = function() { + if (!$scope.user && !$scope.organization) { return; } + if ($scope.loading) { return; } + + $scope.loading = true; + ApiService.getRobots($scope.organization).then(function(resp) { + $scope.robots = resp.robots; + $scope.loading = false; + + if ($routeParams.showRobot) { + var index = $scope.findRobotIndexByName($routeParams.showRobot); + if (index >= 0) { + $scope.showRobot($scope.robots[index]); + } + } + }); + }; + + $scope.$watch('organization', update); + $scope.$watch('user', update); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/role-group.js b/static/js/directives/ui/role-group.js new file mode 100644 index 000000000..d8ca75873 --- /dev/null +++ b/static/js/directives/ui/role-group.js @@ -0,0 +1,29 @@ +/** + * An element which displays a set of roles, and highlights the current role. This control also + * allows the current role to be changed. + */ +angular.module('quay').directive('roleGroup', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/role-group.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'roles': '=roles', + 'currentRole': '=currentRole', + 'roleChanged': '&roleChanged' + }, + controller: function($scope, $element) { + $scope.setRole = function(role) { + if ($scope.currentRole == role) { return; } + if ($scope.roleChanged) { + $scope.roleChanged({'role': role}); + } else { + $scope.currentRole = role; + } + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/setup-trigger-dialog.js b/static/js/directives/ui/setup-trigger-dialog.js new file mode 100644 index 000000000..df0e3fd60 --- /dev/null +++ b/static/js/directives/ui/setup-trigger-dialog.js @@ -0,0 +1,136 @@ +/** + * An element which displays a dialog for setting up a build trigger. + */ +angular.module('quay').directive('setupTriggerDialog', function () { + var directiveDefinitionObject = { + templateUrl: '/static/directives/setup-trigger-dialog.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repository': '=repository', + 'trigger': '=trigger', + 'counter': '=counter', + 'canceled': '&canceled', + 'activated': '&activated' + }, + controller: function($scope, $element, ApiService, UserService) { + var modalSetup = false; + + $scope.state = {}; + $scope.nextStepCounter = -1; + $scope.currentView = 'config'; + + $scope.show = function() { + if (!$scope.trigger || !$scope.repository) { return; } + + $scope.currentView = 'config'; + $('#setupTriggerModal').modal({}); + + if (!modalSetup) { + $('#setupTriggerModal').on('hidden.bs.modal', function () { + if (!$scope.trigger || $scope.trigger['is_active']) { return; } + + $scope.nextStepCounter = -1; + $scope.$apply(function() { + $scope.cancelSetupTrigger(); + }); + }); + + modalSetup = true; + $scope.nextStepCounter = 0; + } + }; + + $scope.isNamespaceAdmin = function(namespace) { + return UserService.isNamespaceAdmin(namespace); + }; + + $scope.cancelSetupTrigger = function() { + $scope.canceled({'trigger': $scope.trigger}); + }; + + $scope.hide = function() { + $('#setupTriggerModal').modal('hide'); + }; + + $scope.checkAnalyze = function(isValid) { + $scope.currentView = 'analyzing'; + $scope.pullInfo = { + 'is_public': true + }; + + if (!isValid) { + $scope.currentView = 'analyzed'; + return; + } + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger.id + }; + + var data = { + 'config': $scope.trigger.config + }; + + ApiService.analyzeBuildTrigger(data, params).then(function(resp) { + $scope.currentView = 'analyzed'; + + if (resp['status'] == 'analyzed') { + if (resp['robots'] && resp['robots'].length > 0) { + $scope.pullInfo['pull_entity'] = resp['robots'][0]; + } else { + $scope.pullInfo['pull_entity'] = null; + } + + $scope.pullInfo['is_public'] = false; + } + + $scope.pullInfo['analysis'] = resp; + }, ApiService.errorDisplay('Cannot load Dockerfile information')); + }; + + $scope.activate = function() { + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger.id + }; + + var data = { + 'config': $scope.trigger['config'] + }; + + if ($scope.pullInfo['pull_entity']) { + data['pull_robot'] = $scope.pullInfo['pull_entity']['name']; + } + + $scope.currentView = 'activating'; + + var errorHandler = ApiService.errorDisplay('Cannot activate build trigger', function(resp) { + $scope.hide(); + $scope.canceled({'trigger': $scope.trigger}); + }); + + ApiService.activateBuildTrigger(data, params).then(function(resp) { + $scope.hide(); + $scope.trigger['is_active'] = true; + $scope.trigger['pull_robot'] = resp['pull_robot']; + $scope.activated({'trigger': $scope.trigger}); + }, errorHandler); + }; + + var check = function() { + if ($scope.counter && $scope.trigger && $scope.repository) { + $scope.show(); + } + }; + + $scope.$watch('trigger', check); + $scope.$watch('counter', check); + $scope.$watch('repository', check); + } + }; + return directiveDefinitionObject; +}); + diff --git a/static/js/directives/ui/signin-form.js b/static/js/directives/ui/signin-form.js new file mode 100644 index 000000000..0625355c5 --- /dev/null +++ b/static/js/directives/ui/signin-form.js @@ -0,0 +1,99 @@ +/** + * An element which displays the sign in form. + */ +angular.module('quay').directive('signinForm', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/signin-form.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'redirectUrl': '=redirectUrl', + 'signInStarted': '&signInStarted', + 'signedIn': '&signedIn' + }, + controller: function($scope, $location, $timeout, $interval, ApiService, KeyService, UserService, CookieService, Features, Config) { + $scope.tryAgainSoon = 0; + $scope.tryAgainInterval = null; + $scope.signingIn = false; + + $scope.markStarted = function() { + $scope.signingIn = true; + if ($scope.signInStarted != null) { + $scope.signInStarted(); + } + }; + + $scope.cancelInterval = function() { + $scope.tryAgainSoon = 0; + + if ($scope.tryAgainInterval) { + $interval.cancel($scope.tryAgainInterval); + } + + $scope.tryAgainInterval = null; + }; + + $scope.$watch('user.username', function() { + $scope.cancelInterval(); + }); + + $scope.$on('$destroy', function() { + $scope.cancelInterval(); + }); + + $scope.signin = function() { + if ($scope.tryAgainSoon > 0) { return; } + + $scope.markStarted(); + $scope.cancelInterval(); + + ApiService.signinUser($scope.user).then(function() { + $scope.signingIn = false; + $scope.needsEmailVerification = false; + $scope.invalidCredentials = false; + + if ($scope.signedIn != null) { + $scope.signedIn(); + } + + // Load the newly created user. + UserService.load(); + + // Redirect to the specified page or the landing page + // Note: The timeout of 500ms is needed to ensure dialogs containing sign in + // forms get removed before the location changes. + $timeout(function() { + var redirectUrl = $scope.redirectUrl; + if (redirectUrl == $location.path() || redirectUrl == null) { + return; + } + window.location = (redirectUrl ? redirectUrl : '/'); + }, 500); + }, function(result) { + $scope.signingIn = false; + + if (result.status == 429 /* try again later */) { + $scope.needsEmailVerification = false; + $scope.invalidCredentials = false; + + $scope.cancelInterval(); + + $scope.tryAgainSoon = result.headers('Retry-After'); + $scope.tryAgainInterval = $interval(function() { + $scope.tryAgainSoon--; + if ($scope.tryAgainSoon <= 0) { + $scope.cancelInterval(); + } + }, 1000, $scope.tryAgainSoon); + } else { + $scope.needsEmailVerification = result.data.needsEmailVerification; + $scope.invalidCredentials = result.data.invalidCredentials; + } + }); + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/signup-form.js b/static/js/directives/ui/signup-form.js new file mode 100644 index 000000000..32981a565 --- /dev/null +++ b/static/js/directives/ui/signup-form.js @@ -0,0 +1,51 @@ +/** + * An element which displays the sign up form. + */ +angular.module('quay').directive('signupForm', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/signup-form.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'inviteCode': '=inviteCode', + + 'userRegistered': '&userRegistered' + }, + controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, Config, UIService) { + $('.form-signup').popover(); + + $scope.awaitingConfirmation = false; + $scope.registering = false; + + $scope.register = function() { + UIService.hidePopover('#signupButton'); + $scope.registering = true; + + if ($scope.inviteCode) { + $scope.newUser['invite_code'] = $scope.inviteCode; + } + + ApiService.createNewUser($scope.newUser).then(function(resp) { + $scope.registering = false; + $scope.awaitingConfirmation = !!resp['awaiting_verification']; + + if (Config.MIXPANEL_KEY) { + mixpanel.alias($scope.newUser.username); + } + + $scope.userRegistered({'username': $scope.newUser.username}); + + if (!$scope.awaitingConfirmation) { + document.location = '/'; + } + }, function(result) { + $scope.registering = false; + UIService.showFormError('#signupButton', result); + }); + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/step-view.js b/static/js/directives/ui/step-view.js new file mode 100644 index 000000000..7c6f09598 --- /dev/null +++ b/static/js/directives/ui/step-view.js @@ -0,0 +1,120 @@ +/** + * An element which displays the steps of the wizard-like dialog, changing them as each step + * is completed. + */ +angular.module('quay').directive('stepView', function ($compile) { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/step-view.html', + replace: true, + transclude: true, + restrict: 'C', + scope: { + 'nextStepCounter': '=nextStepCounter', + 'currentStepValid': '=currentStepValid', + + 'stepsCompleted': '&stepsCompleted' + }, + controller: function($scope, $element, $rootScope) { + this.currentStepIndex = -1; + this.steps = []; + this.watcher = null; + + this.getCurrentStep = function() { + return this.steps[this.currentStepIndex]; + }; + + this.reset = function() { + this.currentStepIndex = -1; + for (var i = 0; i < this.steps.length; ++i) { + this.steps[i].element.hide(); + } + + $scope.currentStepValid = false; + }; + + this.next = function() { + if (this.currentStepIndex >= 0) { + var currentStep = this.getCurrentStep(); + if (!currentStep || !currentStep.scope) { return; } + + if (!currentStep.scope.completeCondition) { + return; + } + + currentStep.element.hide(); + + if (this.unwatch) { + this.unwatch(); + this.unwatch = null; + } + } + + this.currentStepIndex++; + + if (this.currentStepIndex < this.steps.length) { + var currentStep = this.getCurrentStep(); + currentStep.element.show(); + currentStep.scope.load() + + this.unwatch = currentStep.scope.$watch('completeCondition', function(cc) { + $scope.currentStepValid = !!cc; + }); + } else { + $scope.stepsCompleted(); + } + }; + + this.register = function(scope, element) { + element.hide(); + + this.steps.push({ + 'scope': scope, + 'element': element + }); + }; + + var that = this; + $scope.$watch('nextStepCounter', function(nsc) { + if (nsc >= 0) { + that.next(); + } else { + that.reset(); + } + }); + } + }; + return directiveDefinitionObject; +}); + + +/** + * A step in the step view. + */ +angular.module('quay').directive('stepViewStep', function () { + var directiveDefinitionObject = { + priority: 1, + require: '^stepView', + templateUrl: '/static/directives/step-view-step.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'completeCondition': '=completeCondition', + 'loadCallback': '&loadCallback', + 'loadMessage': '@loadMessage' + }, + link: function(scope, element, attrs, controller) { + controller.register(scope, element); + }, + controller: function($scope, $element) { + $scope.load = function() { + $scope.loading = true; + $scope.loadCallback({'callback': function() { + $scope.loading = false; + }}); + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/tag-specific-image-view.js b/static/js/directives/ui/tag-specific-image-view.js new file mode 100644 index 000000000..552d22d2d --- /dev/null +++ b/static/js/directives/ui/tag-specific-image-view.js @@ -0,0 +1,127 @@ +/** + * An element which displays those images which belong to the specified tag *only*. If an image + * is shared between more than a single tag in the repository, then it is not displayed. + */ +angular.module('quay').directive('tagSpecificImagesView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/tag-specific-images-view.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'repository': '=repository', + 'tag': '=tag', + 'images': '=images', + 'imageCutoff': '=imageCutoff' + }, + controller: function($scope, $element, UtilService) { + $scope.getFirstTextLine = UtilService.getFirstMarkdownLineAsText; + + $scope.hasImages = false; + $scope.tagSpecificImages = []; + + $scope.getImageListingClasses = function(image) { + var classes = ''; + if (image.ancestors.length > 1) { + classes += 'child '; + } + + var currentTag = $scope.repository.tags[$scope.tag]; + if (image.id == currentTag.image_id) { + classes += 'tag-image '; + } + + return classes; + }; + + var forAllTagImages = function(tag, callback, opt_cutoff) { + if (!tag) { return; } + + if (!$scope.imageByDockerId) { + $scope.imageByDockerId = []; + for (var i = 0; i < $scope.images.length; ++i) { + var currentImage = $scope.images[i]; + $scope.imageByDockerId[currentImage.id] = currentImage; + } + } + + var tag_image = $scope.imageByDockerId[tag.image_id]; + if (!tag_image) { + return; + } + + callback(tag_image); + + var ancestors = tag_image.ancestors.split('/').reverse(); + for (var i = 0; i < ancestors.length; ++i) { + var image = $scope.imageByDockerId[ancestors[i]]; + if (image) { + if (image == opt_cutoff) { + return; + } + + callback(image); + } + } + }; + + var refresh = function() { + if (!$scope.repository || !$scope.tag || !$scope.images) { + $scope.tagSpecificImages = []; + return; + } + + var tag = $scope.repository.tags[$scope.tag]; + if (!tag) { + $scope.tagSpecificImages = []; + return; + } + + var getIdsForTag = function(currentTag) { + var ids = {}; + forAllTagImages(currentTag, function(image) { + ids[image.id] = true; + }, $scope.imageCutoff); + return ids; + }; + + // Remove any IDs that match other tags. + var toDelete = getIdsForTag(tag); + for (var currentTagName in $scope.repository.tags) { + var currentTag = $scope.repository.tags[currentTagName]; + if (currentTag != tag) { + for (var id in getIdsForTag(currentTag)) { + delete toDelete[id]; + } + } + } + + // Return the matching list of images. + var images = []; + for (var i = 0; i < $scope.images.length; ++i) { + var image = $scope.images[i]; + if (toDelete[image.id]) { + images.push(image); + } + } + + images.sort(function(a, b) { + var result = new Date(b.created) - new Date(a.created); + if (result != 0) { + return result; + } + + return b.sort_index - a.sort_index; + }); + + $scope.tagSpecificImages = images; + }; + + $scope.$watch('repository', refresh); + $scope.$watch('tag', refresh); + $scope.$watch('images', refresh); + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/tour-content.js b/static/js/directives/ui/tour-content.js new file mode 100644 index 000000000..11eb130bf --- /dev/null +++ b/static/js/directives/ui/tour-content.js @@ -0,0 +1,35 @@ +/** + * An element which implements a frame for content in the application tour, making sure to + * chromify any marked elements found. Note that this directive relies on the browserchrome library + * in the lib/ folder. + */ +angular.module('quay').directive('tourContent', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/tour-content.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'kind': '=kind' + }, + controller: function($scope, $element, $timeout, UserService) { + // Monitor any user changes and place the current user into the scope. + UserService.updateUserIn($scope); + + $scope.chromify = function() { + browserchrome.update(); + }; + + $scope.$watch('kind', function(kind) { + $timeout(function() { + $scope.chromify(); + }); + }); + }, + link: function($scope, $element, $attr, ctrl) { + $scope.chromify(); + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/trigger-description.js b/static/js/directives/ui/trigger-description.js new file mode 100644 index 000000000..9b09960bc --- /dev/null +++ b/static/js/directives/ui/trigger-description.js @@ -0,0 +1,21 @@ +/** + * An element which displays information about a build trigger. + */ +angular.module('quay').directive('triggerDescription', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/trigger-description.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'trigger': '=trigger', + 'short': '=short' + }, + controller: function($scope, $element, KeyService, TriggerService) { + $scope.KeyService = KeyService; + $scope.TriggerService = TriggerService; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/trigger-setup-github.js b/static/js/directives/ui/trigger-setup-github.js new file mode 100644 index 000000000..7094c658d --- /dev/null +++ b/static/js/directives/ui/trigger-setup-github.js @@ -0,0 +1,219 @@ +/** + * An element which displays github-specific setup information for its build triggers. + */ +angular.module('quay').directive('triggerSetupGithub', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/trigger-setup-github.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repository': '=repository', + 'trigger': '=trigger', + + 'nextStepCounter': '=nextStepCounter', + 'currentStepValid': '=currentStepValid', + + 'analyze': '&analyze' + }, + controller: function($scope, $element, ApiService) { + $scope.analyzeCounter = 0; + $scope.setupReady = false; + $scope.refs = null; + $scope.branchNames = null; + $scope.tagNames = null; + + $scope.state = { + 'currentRepo': null, + 'branchTagFilter': '', + 'hasBranchTagFilter': false, + 'isInvalidLocation': true, + 'currentLocation': null + }; + + $scope.isMatching = function(kind, name, filter) { + try { + var patt = new RegExp(filter); + } catch (ex) { + return false; + } + + var fullname = (kind + '/' + name); + var m = fullname.match(patt); + return m && m[0].length == fullname.length; + } + + $scope.addRef = function(kind, name) { + if ($scope.isMatching(kind, name, $scope.state.branchTagFilter)) { + return; + } + + var newFilter = kind + '/' + name; + var existing = $scope.state.branchTagFilter; + if (existing) { + $scope.state.branchTagFilter = '(' + existing + ')|(' + newFilter + ')'; + } else { + $scope.state.branchTagFilter = newFilter; + } + } + + $scope.stepsCompleted = function() { + $scope.analyze({'isValid': !$scope.state.isInvalidLocation}); + }; + + $scope.loadRepositories = function(callback) { + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger.id + }; + + ApiService.listTriggerBuildSources(null, params).then(function(resp) { + $scope.orgs = resp['sources']; + setupTypeahead(); + callback(); + }, ApiService.errorDisplay('Cannot load repositories')); + }; + + $scope.loadBranchesAndTags = function(callback) { + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger['id'], + 'field_name': 'refs' + }; + + ApiService.listTriggerFieldValues($scope.trigger['config'], params).then(function(resp) { + $scope.refs = resp['values']; + $scope.branchNames = []; + $scope.tagNames = []; + + for (var i = 0; i < $scope.refs.length; ++i) { + var ref = $scope.refs[i]; + if (ref.kind == 'branch') { + $scope.branchNames.push(ref.name); + } else { + $scope.tagNames.push(ref.name); + } + } + + callback(); + }, ApiService.errorDisplay('Cannot load branch and tag names')); + }; + + $scope.loadLocations = function(callback) { + $scope.locations = null; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger.id + }; + + ApiService.listBuildTriggerSubdirs($scope.trigger['config'], params).then(function(resp) { + if (resp['status'] == 'error') { + callback(resp['message'] || 'Could not load Dockerfile locations'); + return; + } + + $scope.locations = resp['subdir'] || []; + + // Select a default location (if any). + if ($scope.locations.length > 0) { + $scope.setLocation($scope.locations[0]); + } else { + $scope.state.currentLocation = null; + $scope.state.isInvalidLocation = resp['subdir'].indexOf('') < 0; + $scope.trigger.$ready = true; + } + + callback(); + }, ApiService.errorDisplay('Cannot load locations')); + } + + $scope.handleLocationInput = function(location) { + $scope.state.isInvalidLocation = $scope.locations.indexOf(location) < 0; + $scope.trigger['config']['subdir'] = location || ''; + $scope.trigger.$ready = true; + }; + + $scope.handleLocationSelected = function(datum) { + $scope.setLocation(datum['value']); + }; + + $scope.setLocation = function(location) { + $scope.state.currentLocation = location; + $scope.state.isInvalidLocation = false; + $scope.trigger['config']['subdir'] = location || ''; + $scope.trigger.$ready = true; + }; + + $scope.selectRepo = function(repo, org) { + $scope.state.currentRepo = { + 'repo': repo, + 'avatar_url': org['info']['avatar_url'], + 'toString': function() { + return this.repo; + } + }; + }; + + $scope.selectRepoInternal = function(currentRepo) { + $scope.trigger.$ready = false; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger['id'] + }; + + var repo = currentRepo['repo']; + $scope.trigger['config'] = { + 'build_source': repo, + 'subdir': '' + }; + }; + + var setupTypeahead = function() { + var repos = []; + for (var i = 0; i < $scope.orgs.length; ++i) { + var org = $scope.orgs[i]; + var orepos = org['repos']; + for (var j = 0; j < orepos.length; ++j) { + var repoValue = { + 'repo': orepos[j], + 'avatar_url': org['info']['avatar_url'], + 'toString': function() { + return this.repo; + } + }; + var datum = { + 'name': orepos[j], + 'org': org, + 'value': orepos[j], + 'title': orepos[j], + 'item': repoValue + }; + repos.push(datum); + } + } + + $scope.repoLookahead = repos; + }; + + $scope.$watch('state.currentRepo', function(repo) { + if (repo) { + $scope.selectRepoInternal(repo); + } + }); + + $scope.$watch('state.branchTagFilter', function(bf) { + if (!$scope.trigger) { return; } + + if ($scope.state.hasBranchTagFilter) { + $scope.trigger['config']['branchtag_regex'] = bf; + } else { + delete $scope.trigger['config']['branchtag_regex']; + } + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/twitter-view.js b/static/js/directives/ui/twitter-view.js new file mode 100644 index 000000000..f9d632729 --- /dev/null +++ b/static/js/directives/ui/twitter-view.js @@ -0,0 +1,22 @@ +/** + * An element which displays a twitter message and author information. + */ +angular.module('quay').directive('twitterView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/twitter-view.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'avatarUrl': '@avatarUrl', + 'authorName': '@authorName', + 'authorUser': '@authorUser', + 'messageUrl': '@messageUrl', + 'messageDate': '@messageDate' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/usage-chart.js b/static/js/directives/ui/usage-chart.js new file mode 100644 index 000000000..5eb0edfa3 --- /dev/null +++ b/static/js/directives/ui/usage-chart.js @@ -0,0 +1,50 @@ +/** + * An element which displays a donut chart, along with warnings if the limit is close to being + * reached. + */ +angular.module('quay').directive('usageChart', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/usage-chart.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'current': '=current', + 'total': '=total', + 'limit': '=limit', + 'usageTitle': '@usageTitle' + }, + controller: function($scope, $element) { + $scope.limit = ""; + + var chart = null; + + var update = function() { + if ($scope.current == null || $scope.total == null) { return; } + if (!chart) { + chart = new UsageChart(); + chart.draw('usage-chart-element'); + } + + var current = $scope.current || 0; + var total = $scope.total || 0; + if (current > total) { + $scope.limit = 'over'; + } else if (current == total) { + $scope.limit = 'at'; + } else if (current >= total * 0.7) { + $scope.limit = 'near'; + } else { + $scope.limit = 'none'; + } + + chart.update($scope.current, $scope.total); + }; + + $scope.$watch('current', update); + $scope.$watch('total', update); + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/user-setup.js b/static/js/directives/ui/user-setup.js new file mode 100644 index 000000000..f03faf025 --- /dev/null +++ b/static/js/directives/ui/user-setup.js @@ -0,0 +1,47 @@ +/** + * An element which displays a box for the user to sign in, sign up and recover their account. + */ +angular.module('quay').directive('userSetup', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/user-setup.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'redirectUrl': '=redirectUrl', + + 'inviteCode': '=inviteCode', + + 'signInStarted': '&signInStarted', + 'signedIn': '&signedIn', + 'userRegistered': '&userRegistered' + }, + controller: function($scope, $location, $timeout, ApiService, KeyService, UserService) { + $scope.sendRecovery = function() { + $scope.sendingRecovery = true; + + ApiService.requestRecoveryEmail($scope.recovery).then(function() { + $scope.invalidRecovery = false; + $scope.errorMessage = ''; + $scope.sent = true; + $scope.sendingRecovery = false; + }, function(resp) { + $scope.invalidRecovery = true; + $scope.errorMessage = ApiService.getErrorMessage(resp, 'Cannot send recovery email'); + $scope.sent = false; + $scope.sendingRecovery = false; + }); + }; + + $scope.handleUserRegistered = function(username) { + $scope.userRegistered({'username': username}); + }; + + $scope.hasSignedIn = function() { + return UserService.hasEverLoggedIn(); + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/graphing.js b/static/js/graphing.js index 18bb09496..d25007a9e 100644 --- a/static/js/graphing.js +++ b/static/js/graphing.js @@ -521,6 +521,11 @@ ImageHistoryTree.prototype.pruneUnreferenced_ = function(node) { } node.children = surviving_children; } + + if (!node.tags) { + return true; + } + return (node.children.length == 0 && node.tags.length == 0); }; diff --git a/static/js/pages/about.js b/static/js/pages/about.js new file mode 100644 index 000000000..c193330bb --- /dev/null +++ b/static/js/pages/about.js @@ -0,0 +1,11 @@ +(function() { + /** + * About page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('about', 'about.html', null, { + 'title': 'About Us', + 'description': 'About Quay.io' + }); + }]); +}()) \ No newline at end of file diff --git a/static/js/pages/build-package.js b/static/js/pages/build-package.js new file mode 100644 index 000000000..2b6f7ce63 --- /dev/null +++ b/static/js/pages/build-package.js @@ -0,0 +1,145 @@ +(function() { + /** + * Page which displays a build package for a specific build. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('build-package', 'build-package.html', BuildPackageCtrl); + }]); + + function BuildPackageCtrl($scope, Restangular, ApiService, DataFileService, $routeParams, $rootScope, $location, $timeout) { + var namespace = $routeParams.namespace; + var name = $routeParams.name; + var buildid = $routeParams.buildid; + + var params = { + 'repository': namespace + '/' + name, + 'build_uuid': buildid + }; + + $scope.initializeTree = function() { + if ($scope.drawn) { + $scope.tree.notifyResized(); + return; + } + + $scope.drawn = true; + $timeout(function() { + $scope.tree.draw('file-tree-container'); + }, 10); + }; + + var determineDockerfilePath = function() { + var dockerfilePath = 'Dockerfile'; + if ($scope.repobuild['job_config']) { + var dockerfileFolder = ($scope.repobuild['job_config']['build_subdir'] || ''); + if (dockerfileFolder[0] == '/') { + dockerfileFolder = dockerfileFolder.substr(1); + } + if (dockerfileFolder && dockerfileFolder[dockerfileFolder.length - 1] != '/') { + dockerfileFolder += '/'; + } + dockerfilePath = dockerfileFolder + 'Dockerfile'; + } + return dockerfilePath; + }; + + var processBuildPack = function(uint8array) { + var archiveread = function(files) { + var getpath = function(file) { + return file.path; + }; + + var findFile = function(path) { + for (var i = 0; i < files.length; ++i) { + var file = files[i]; + if (file.path == path) { + return file; + } + } + return null; + }; + + $scope.tree = new FileTree($.map(files, getpath)); + $($scope.tree).bind('fileClicked', function(e) { + var file = findFile(e.path); + if (file && file.canRead) { + saveAs(file.toBlob(), file.name); + } + }); + + var dockerfilePath = determineDockerfilePath(); + var dockerfile = findFile(dockerfilePath); + if (dockerfile && dockerfile.canRead) { + DataFileService.blobToString(dockerfile.toBlob(), function(result) { + $scope.$apply(function() { + $scope.dockerFilePath = dockerfilePath || 'Dockerfile'; + $scope.dockerFileContents = result; + }); + }); + } + + $scope.loaded = true; + }; + + var notarchive = function() { + DataFileService.arrayToString(uint8array, function(r) { + $scope.dockerFilePath = 'Dockerfile'; + $scope.dockerFileContents = r; + $scope.loaded = true; + }); + }; + + setTimeout(function() { + $scope.$apply(function() { + DataFileService.readDataArrayAsPossibleArchive(uint8array, archiveread, notarchive); + }); + }, 0); + }; + + var downloadBuildPack = function(url) { + $scope.downloadProgress = 0; + $scope.downloading = true; + startDownload(url); + }; + + var startDownload = function(url) { + var onprogress = function(p) { + $scope.downloadProgress = p * 100; + }; + + var onerror = function() { + $scope.downloading = false; + $scope.downloadError = true; + }; + + var onloaded = function(uint8array) { + $scope.downloading = false; + processBuildPack(uint8array); + }; + + DataFileService.downloadDataFileAsArrayBuffer($scope, url, onprogress, onerror, onloaded); + }; + + var getBuildInfo = function() { + $scope.repository_build = ApiService.getRepoBuildStatus(null, params, true).then(function(resp) { + if (!resp['is_writer']) { + $rootScope.title = 'Unknown build'; + $scope.accessDenied = true; + return; + } + + $rootScope.title = 'Repository Build Pack - ' + resp['display_name']; + $scope.repobuild = resp; + $scope.repo = { + 'namespace': namespace, + 'name': name + }; + + downloadBuildPack(resp['archive_url']); + return resp; + }); + }; + + getBuildInfo(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/confirm-invite.js b/static/js/pages/confirm-invite.js new file mode 100644 index 000000000..5d279c413 --- /dev/null +++ b/static/js/pages/confirm-invite.js @@ -0,0 +1,40 @@ +(function() { + /** + * Page for confirming an invite to a team. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('confirm-invite', 'confirm-invite.html', ConfirmInviteCtrl, { + 'title': 'Confirm Invitation' + }); + }]); + + function ConfirmInviteCtrl($scope, $location, UserService, ApiService, NotificationService) { + // Monitor any user changes and place the current user into the scope. + $scope.loading = false; + $scope.inviteCode = $location.search()['code'] || ''; + + UserService.updateUserIn($scope, function(user) { + if (!user.anonymous && !$scope.loading) { + // Make sure to not redirect now that we have logged in. We'll conduct the redirect + // manually. + $scope.redirectUrl = null; + $scope.loading = true; + + var params = { + 'code': $location.search()['code'] + }; + + ApiService.acceptOrganizationTeamInvite(null, params).then(function(resp) { + NotificationService.update(); + UserService.load(); + $location.path('/organization/' + resp.org + '/teams/' + resp.team); + }, function(resp) { + $scope.loading = false; + $scope.invalid = ApiService.getErrorMessage(resp, 'Invalid confirmation code'); + }); + } + }); + + $scope.redirectUrl = window.location.href; + } +})(); \ No newline at end of file diff --git a/static/js/pages/contact.js b/static/js/pages/contact.js new file mode 100644 index 000000000..d83ed0ee0 --- /dev/null +++ b/static/js/pages/contact.js @@ -0,0 +1,55 @@ +(function() { + /** + * Contact details page. The contacts are configurable. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('contact', 'contact.html', ContactCtrl, { + 'title': 'Contact Us' + }); + }]); + + function ContactCtrl($scope, Config) { + $scope.Config = Config; + $scope.colsize = Math.floor(12 / Config.CONTACT_INFO.length); + + $scope.getKind = function(contactInfo) { + var colon = contactInfo.indexOf(':'); + var scheme = contactInfo.substr(0, colon); + if (scheme == 'https' || scheme == 'http') { + if (contactInfo.indexOf('//twitter.com/') > 0) { + return 'twitter'; + } + + return 'url'; + } + + return scheme; + }; + + $scope.getTitle = function(contactInfo) { + switch ($scope.getKind(contactInfo)) { + case 'url': + return contactInfo; + + case 'twitter': + var parts = contactInfo.split('/'); + return '@' + parts[parts.length - 1]; + + case 'tel': + return contactInfo.substr('tel:'.length); + + case 'irc': + // irc://chat.freenode.net:6665/quayio + var parts = contactInfo.substr('irc://'.length).split('/'); + var server = parts[0]; + if (server.indexOf('freenode') > 0) { + server = 'Freenode'; + } + return server + ': #' + parts[parts.length - 1]; + + case 'mailto': + return contactInfo.substr('mailto:'.length); + } + } + } +})(); \ No newline at end of file diff --git a/static/js/pages/exp-new-layout.js b/static/js/pages/exp-new-layout.js new file mode 100644 index 000000000..3608d2187 --- /dev/null +++ b/static/js/pages/exp-new-layout.js @@ -0,0 +1,19 @@ +(function() { + /** + * Experiment enable page: New layout + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('exp-new-layout', 'exp-new-layout.html', ExpCtrl, { + 'newLayout': true + }); + }]); + + function ExpCtrl($scope, CookieService) { + $scope.isEnabled = CookieService.get('quay.exp-new-layout') == 'true'; + + $scope.setEnabled = function(value) { + $scope.isEnabled = value; + CookieService.putPermanent('quay.exp-new-layout', value.toString()); + }; + } +}()) \ No newline at end of file diff --git a/static/js/pages/image-view.js b/static/js/pages/image-view.js new file mode 100644 index 000000000..a8f04deda --- /dev/null +++ b/static/js/pages/image-view.js @@ -0,0 +1,133 @@ +(function() { + /** + * Page to view the details of a single image. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('image-view', 'image-view.html', ImageViewCtrl); + }]); + + function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, ImageMetadataService) { + var namespace = $routeParams.namespace; + var name = $routeParams.name; + var imageid = $routeParams.image; + + $scope.getFormattedCommand = ImageMetadataService.getFormattedCommand; + + $scope.parseDate = function(dateString) { + return Date.parse(dateString); + }; + + $scope.getFolder = function(filepath) { + var index = filepath.lastIndexOf('/'); + if (index < 0) { + return ''; + } + return filepath.substr(0, index + 1); + }; + + $scope.getFolders = function(filepath) { + var index = filepath.lastIndexOf('/'); + if (index < 0) { + return ''; + } + + return filepath.substr(0, index).split('/'); + }; + + $scope.getFilename = function(filepath) { + var index = filepath.lastIndexOf('/'); + if (index < 0) { + return filepath; + } + return filepath.substr(index + 1); + }; + + $scope.setFolderFilter = function(folderPath, index) { + var parts = folderPath.split('/'); + parts = parts.slice(0, index + 1); + $scope.setFilter(parts.join('/')); + }; + + $scope.setFilter = function(filter) { + $scope.search = {}; + $scope.search['$'] = filter; + document.getElementById('change-filter').value = filter; + }; + + $scope.initializeTree = function() { + if ($scope.tree) { return; } + + $scope.tree = new ImageFileChangeTree($scope.image, $scope.combinedChanges); + $timeout(function() { + $scope.tree.draw('changes-tree-container'); + }, 10); + }; + + var fetchRepository = function() { + var params = { + 'repository': namespace + '/' + name + }; + + ApiService.getRepoAsResource(params).get(function(repo) { + $scope.repo = repo; + }); + }; + + var fetchImage = function() { + var params = { + 'repository': namespace + '/' + name, + 'image_id': imageid + }; + + $scope.image = ApiService.getImageAsResource(params).get(function(image) { + if (!$scope.repo) { + $scope.repo = { + 'name': name, + 'namespace': namespace, + 'is_public': true + }; + } + + $rootScope.title = 'View Image - ' + image.id; + $rootScope.description = 'Viewing docker image ' + image.id + ' under repository ' + namespace + '/' + name + + ': Image changes tree and list view'; + + // Fetch the image's changes. + fetchChanges(); + return image; + }); + }; + + var fetchChanges = function() { + var params = { + 'repository': namespace + '/' + name, + 'image_id': imageid + }; + + ApiService.getImageChanges(null, params).then(function(changes) { + var combinedChanges = []; + var addCombinedChanges = function(c, kind) { + for (var i = 0; i < c.length; ++i) { + combinedChanges.push({ + 'kind': kind, + 'file': c[i] + }); + } + }; + + addCombinedChanges(changes.added, 'added'); + addCombinedChanges(changes.removed, 'removed'); + addCombinedChanges(changes.changed, 'changed'); + + $scope.combinedChanges = combinedChanges; + $scope.imageChanges = changes; + }); + }; + + // Fetch the repository. + fetchRepository(); + + // Fetch the image. + fetchImage(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/landing.js b/static/js/pages/landing.js new file mode 100644 index 000000000..eefd7e400 --- /dev/null +++ b/static/js/pages/landing.js @@ -0,0 +1,115 @@ +(function() { + /** + * Landing page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('landing', 'landing.html', LandingCtrl, { + 'pageClass': 'landing-page' + }); + }]); + + function LandingCtrl($scope, UserService, ApiService, Features, Config) { + $scope.namespace = null; + $scope.currentScreenshot = 'repo-view'; + + $scope.$watch('namespace', function(namespace) { + loadMyRepos(namespace); + }); + + UserService.updateUserIn($scope, function() { + loadMyRepos($scope.namespace); + }); + + $scope.changeScreenshot = function(screenshot) { + $scope.currentScreenshot = screenshot; + }; + + $scope.canCreateRepo = function(namespace) { + if (!$scope.user) { return false; } + + if (namespace == $scope.user.username) { + return true; + } + + if ($scope.user.organizations) { + for (var i = 0; i < $scope.user.organizations.length; ++i) { + var org = $scope.user.organizations[i]; + if (org.name == namespace) { + return org.can_create_repo; + } + } + } + + return false; + }; + + var loadMyRepos = function(namespace) { + if (!$scope.user || $scope.user.anonymous || !namespace) { + return; + } + + var options = {'limit': 4, 'public': false, 'sort': true, 'namespace': namespace }; + $scope.my_repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) { + return resp.repositories; + }); + }; + + $scope.chromify = function() { + browserchrome.update(); + + var jcarousel = $('.jcarousel'); + + jcarousel + .on('jcarousel:reload jcarousel:create', function () { + var width = jcarousel.innerWidth(); + jcarousel.jcarousel('items').css('width', width + 'px'); + }) + .jcarousel({ + wrap: 'circular' + }); + + $('.jcarousel-control-prev') + .on('jcarouselcontrol:active', function() { + $(this).removeClass('inactive'); + }) + .on('jcarouselcontrol:inactive', function() { + $(this).addClass('inactive'); + }) + .jcarouselControl({ + target: '-=1' + }); + + $('.jcarousel-control-next') + .on('jcarouselcontrol:active', function() { + $(this).removeClass('inactive'); + }) + .on('jcarouselcontrol:inactive', function() { + $(this).addClass('inactive'); + }) + .jcarouselControl({ + target: '+=1' + }); + + $('.jcarousel-pagination') + .on('jcarouselpagination:active', 'a', function() { + $(this).addClass('active'); + }) + .on('jcarouselpagination:inactive', 'a', function() { + $(this).removeClass('active'); + }) + .jcarouselPagination({ + 'item': function(page, carouselItems) { + return ''; + } + }); + }; + + $scope.getEnterpriseLogo = function() { + if (!Config.ENTERPRISE_LOGO_URL) { + return '/static/img/quay-logo.png'; + } + + return Config.ENTERPRISE_LOGO_URL; + }; + } +})(); diff --git a/static/js/pages/manage-application.js b/static/js/pages/manage-application.js new file mode 100644 index 000000000..804696de8 --- /dev/null +++ b/static/js/pages/manage-application.js @@ -0,0 +1,120 @@ +(function() { + /** + * Page for managing an organization-defined OAuth application. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('manage-application', 'manage-application.html', ManageApplicationCtrl); + }]); + + function ManageApplicationCtrl($scope, $routeParams, $rootScope, $location, $timeout, OAuthService, ApiService, UserService, Config) { + var orgname = $routeParams.orgname; + var clientId = $routeParams.clientid; + + $scope.Config = Config; + $scope.OAuthService = OAuthService; + $scope.updating = false; + + $scope.genScopes = {}; + + UserService.updateUserIn($scope); + + $scope.getScopes = function(scopes) { + var checked = []; + for (var scopeName in scopes) { + if (scopes.hasOwnProperty(scopeName) && scopes[scopeName]) { + checked.push(scopeName); + } + } + return checked; + }; + + $scope.askResetClientSecret = function() { + $('#resetSecretModal').modal({}); + }; + + $scope.askDelete = function() { + $('#deleteAppModal').modal({}); + }; + + $scope.deleteApplication = function() { + var params = { + 'orgname': orgname, + 'client_id': clientId + }; + + $('#deleteAppModal').modal('hide'); + + ApiService.deleteOrganizationApplication(null, params).then(function(resp) { + $timeout(function() { + $location.path('/organization/' + orgname + '/admin'); + }, 500); + }, ApiService.errorDisplay('Could not delete application')); + }; + + $scope.updateApplication = function() { + $scope.updating = true; + var params = { + 'orgname': orgname, + 'client_id': clientId + }; + + if (!$scope.application['description']) { + delete $scope.application['description']; + } + + if (!$scope.application['avatar_email']) { + delete $scope.application['avatar_email']; + } + + var errorHandler = ApiService.errorDisplay('Could not update application', function(resp) { + $scope.updating = false; + }); + + ApiService.updateOrganizationApplication($scope.application, params).then(function(resp) { + $scope.application = resp; + }, errorHandler); + }; + + $scope.resetClientSecret = function() { + var params = { + 'orgname': orgname, + 'client_id': clientId + }; + + $('#resetSecretModal').modal('hide'); + + ApiService.resetOrganizationApplicationClientSecret(null, params).then(function(resp) { + $scope.application = resp; + }, ApiService.errorDisplay('Could not reset client secret')); + }; + + var loadOrganization = function() { + $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { + $scope.organization = org; + return org; + }); + }; + + var loadApplicationInfo = function() { + var params = { + 'orgname': orgname, + 'client_id': clientId + }; + + $scope.appResource = ApiService.getOrganizationApplicationAsResource(params).get(function(resp) { + $scope.application = resp; + + $rootScope.title = 'Manage Application ' + $scope.application.name + ' (' + $scope.orgname + ')'; + $rootScope.description = 'Manage the details of application ' + $scope.application.name + + ' under organization ' + $scope.orgname; + + return resp; + }); + }; + + + // Load the organization and application info. + loadOrganization(); + loadApplicationInfo(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/new-organization.js b/static/js/pages/new-organization.js new file mode 100644 index 000000000..5508da383 --- /dev/null +++ b/static/js/pages/new-organization.js @@ -0,0 +1,100 @@ +(function() { + /** + * Page for creating a new organization. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('new-organization', 'new-organization.html', NewOrgCtrl, { + 'title': 'New Organization', + 'description': 'Create a new organization to manage teams and permissions' + }); + }]); + + function NewOrgCtrl($scope, $routeParams, $timeout, $location, UserService, PlanService, ApiService, CookieService, Features) { + $scope.Features = Features; + $scope.holder = {}; + + UserService.updateUserIn($scope); + + var requested = $routeParams['plan']; + + if (Features.BILLING) { + // Load the list of plans. + PlanService.getPlans(function(plans) { + $scope.plans = plans; + $scope.holder.currentPlan = null; + if (requested) { + PlanService.getPlan(requested, function(plan) { + $scope.holder.currentPlan = plan; + }); + } + }); + } + + $scope.signedIn = function() { + if (Features.BILLING) { + PlanService.handleNotedPlan(); + } + }; + + $scope.signinStarted = function() { + if (Features.BILLING) { + PlanService.getMinimumPlan(1, true, function(plan) { + PlanService.notePlan(plan.stripeId); + }); + } + }; + + $scope.setPlan = function(plan) { + $scope.holder.currentPlan = plan; + }; + + $scope.createNewOrg = function() { + $('#orgName').popover('hide'); + + $scope.creating = true; + var org = $scope.org; + var data = { + 'name': org.name, + 'email': org.email + }; + + ApiService.createOrganization(data).then(function(created) { + $scope.created = created; + + // Reset the organizations list. + UserService.load(); + + // Set the default namesapce to the organization. + CookieService.putPermanent('quay.namespace', org.name); + + var showOrg = function() { + $scope.creating = false; + $location.path('/organization/' + org.name + '/'); + }; + + // If the selected plan is free, simply move to the org page. + if (!Features.BILLING || $scope.holder.currentPlan.price == 0) { + showOrg(); + return; + } + + // Otherwise, show the subscribe for the plan. + $scope.creating = true; + var callbacks = { + 'opened': function() { $scope.creating = true; }, + 'closed': showOrg, + 'success': showOrg, + 'failure': showOrg + }; + + PlanService.changePlan($scope, org.name, $scope.holder.currentPlan.stripeId, callbacks); + }, function(resp) { + $scope.creating = false; + $scope.createError = ApiService.getErrorMessage(resp); + $timeout(function() { + $('#orgName').popover('show'); + }); + }); + }; + } +})(); \ No newline at end of file diff --git a/static/js/pages/new-repo.js b/static/js/pages/new-repo.js new file mode 100644 index 000000000..79cac4762 --- /dev/null +++ b/static/js/pages/new-repo.js @@ -0,0 +1,169 @@ +(function() { + /** + * Page to create a new repository. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('new-repo', 'new-repo.html', NewRepoCtrl, { + 'title': 'New Repository', + 'description': 'Create a new Docker repository' + }); + }]); + + function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService, PlanService, TriggerService, Features) { + UserService.updateUserIn($scope); + + $scope.Features = Features; + + $scope.repo = { + 'is_public': 0, + 'description': '', + 'initialize': '' + }; + + // Watch the namespace on the repo. If it changes, we update the plan and the public/private + // accordingly. + $scope.isUserNamespace = true; + $scope.$watch('repo.namespace', function(namespace) { + // Note: Can initially be undefined. + if (!namespace) { return; } + + var isUserNamespace = (namespace == $scope.user.username); + + $scope.planRequired = null; + $scope.isUserNamespace = isUserNamespace; + + // Determine whether private repositories are allowed for the namespace. + checkPrivateAllowed(); + }); + + $scope.changeNamespace = function(namespace) { + $scope.repo.namespace = namespace; + }; + + $scope.handleBuildStarted = function() { + var repo = $scope.repo; + $location.path('/repository/' + repo.namespace + '/' + repo.name); + }; + + $scope.handleBuildFailed = function(message) { + var repo = $scope.repo; + + bootbox.dialog({ + "message": message, + "title": "Could not start Dockerfile build", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary", + "callback": function() { + $scope.$apply(function() { + $location.path('/repository/' + repo.namespace + '/' + repo.name); + }); + } + } + } + }); + + return true; + }; + + $scope.createNewRepo = function() { + $('#repoName').popover('hide'); + + $scope.creating = true; + var repo = $scope.repo; + var data = { + 'namespace': repo.namespace, + 'repository': repo.name, + 'visibility': repo.is_public == '1' ? 'public' : 'private', + 'description': repo.description + }; + + ApiService.createRepo(data).then(function(created) { + $scope.creating = false; + $scope.created = created; + + // Start the upload process if applicable. + if ($scope.repo.initialize == 'dockerfile' || $scope.repo.initialize == 'zipfile') { + $scope.createdForBuild = created; + return; + } + + // Conduct the Github redirect if applicable. + if ($scope.repo.initialize == 'github') { + window.location = TriggerService.getRedirectUrl('github', repo.namespace, repo.name); + return; + } + + // Otherwise, redirect to the repo page. + $location.path('/repository/' + created.namespace + '/' + created.name); + }, function(result) { + $scope.creating = false; + $scope.createError = result.data ? result.data.message : 'Cannot create repository'; + $timeout(function() { + $('#repoName').popover('show'); + }); + }); + }; + + $scope.upgradePlan = function() { + var callbacks = { + 'started': function() { $scope.planChanging = true; }, + 'opened': function() { $scope.planChanging = true; }, + 'closed': function() { $scope.planChanging = false; }, + 'success': subscribedToPlan, + 'failure': function(resp) { + $('#couldnotsubscribeModal').modal(); + $scope.planChanging = false; + } + }; + + var namespace = $scope.isUserNamespace ? null : $scope.repo.namespace; + PlanService.changePlan($scope, namespace, $scope.planRequired.stripeId, callbacks); + }; + + var checkPrivateAllowed = function() { + if (!$scope.repo || !$scope.repo.namespace) { return; } + + if (!Features.BILLING) { + $scope.checkingPlan = false; + $scope.planRequired = null; + return; + } + + $scope.checkingPlan = true; + + var isUserNamespace = $scope.isUserNamespace; + ApiService.getPrivateAllowed(isUserNamespace ? null : $scope.repo.namespace).then(function(resp) { + $scope.checkingPlan = false; + + if (resp['privateAllowed']) { + $scope.planRequired = null; + return; + } + + if (resp['privateCount'] == null) { + // Organization where we are not the admin. + $scope.planRequired = {}; + return; + } + + // Otherwise, lookup the matching plan. + PlanService.getMinimumPlan(resp['privateCount'] + 1, !isUserNamespace, function(minimum) { + $scope.planRequired = minimum; + }); + }); + }; + + var subscribedToPlan = function(sub) { + $scope.planChanging = false; + $scope.subscription = sub; + + PlanService.getPlan(sub.plan, function(subscribedPlan) { + $scope.subscribedPlan = subscribedPlan; + $scope.planRequired = null; + checkPrivateAllowed(); + }); + }; + } +})(); \ No newline at end of file diff --git a/static/js/pages/org-admin.js b/static/js/pages/org-admin.js new file mode 100644 index 000000000..194366f8a --- /dev/null +++ b/static/js/pages/org-admin.js @@ -0,0 +1,110 @@ +(function() { + /** + * Organization admin/settings page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('org-admin', 'org-admin.html', OrgAdminCtrl); + }]); + + function OrgAdminCtrl($rootScope, $scope, $timeout, Restangular, $routeParams, UserService, PlanService, ApiService, Features, UIService) { + var orgname = $routeParams.orgname; + + // Load the list of plans. + if (Features.BILLING) { + PlanService.getPlans(function(plans) { + $scope.plans = plans; + $scope.plan_map = {}; + + for (var i = 0; i < plans.length; ++i) { + $scope.plan_map[plans[i].stripeId] = plans[i]; + } + }); + } + + $scope.orgname = orgname; + $scope.membersLoading = true; + $scope.membersFound = null; + $scope.invoiceLoading = true; + $scope.logsShown = 0; + $scope.invoicesShown = 0; + $scope.applicationsShown = 0; + $scope.changingOrganization = false; + + $scope.loadLogs = function() { + $scope.logsShown++; + }; + + $scope.loadApplications = function() { + $scope.applicationsShown++; + }; + + $scope.loadInvoices = function() { + $scope.invoicesShown++; + }; + + $scope.planChanged = function(plan) { + $scope.hasPaidPlan = plan && plan.price > 0; + }; + + $scope.$watch('organizationEmail', function(e) { + UIService.hidePopover('#changeEmailForm'); + }); + + $scope.changeEmail = function() { + UIService.hidePopover('#changeEmailForm'); + + $scope.changingOrganization = true; + var params = { + 'orgname': orgname + }; + + var data = { + 'email': $scope.organizationEmail + }; + + ApiService.changeOrganizationDetails(data, params).then(function(org) { + $scope.changingOrganization = false; + $scope.changeEmailForm.$setPristine(); + $scope.organization = org; + }, function(result) { + $scope.changingOrganization = false; + UIService.showFormError('#changeEmailForm', result); + }); + }; + + $scope.loadMembers = function() { + if ($scope.membersFound) { return; } + $scope.membersLoading = true; + + var params = { + 'orgname': orgname + }; + + ApiService.getOrganizationMembers(null, params).then(function(resp) { + var membersArray = []; + for (var key in resp.members) { + if (resp.members.hasOwnProperty(key)) { + membersArray.push(resp.members[key]); + } + } + + $scope.membersFound = membersArray; + $scope.membersLoading = false; + }); + }; + + var loadOrganization = function() { + $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { + if (org && org.is_admin) { + $scope.organization = org; + $scope.organizationEmail = org.email; + $rootScope.title = orgname + ' (Admin)'; + $rootScope.description = 'Administration page for organization ' + orgname; + } + }); + }; + + // Load the organization. + loadOrganization(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/org-member-logs.js b/static/js/pages/org-member-logs.js new file mode 100644 index 000000000..be131357f --- /dev/null +++ b/static/js/pages/org-member-logs.js @@ -0,0 +1,49 @@ +(function() { + /** + * Page for displaying the logs of a member in an organization. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('org-member-logs', 'org-member-logs.html', OrgMemberLogsCtrl); + }]); + + function OrgMemberLogsCtrl($scope, $routeParams, $rootScope, $timeout, Restangular, ApiService) { + var orgname = $routeParams.orgname; + var membername = $routeParams.membername; + + $scope.orgname = orgname; + $scope.memberInfo = null; + $scope.ready = false; + + var loadOrganization = function() { + $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { + $scope.organization = org; + return org; + }); + }; + + var loadMemberInfo = function() { + var params = { + 'orgname': orgname, + 'membername': membername + }; + + $scope.memberResource = ApiService.getOrganizationMemberAsResource(params).get(function(resp) { + $scope.memberInfo = resp.member; + + $rootScope.title = 'Logs for ' + $scope.memberInfo.name + ' (' + $scope.orgname + ')'; + $rootScope.description = 'Shows all the actions of ' + $scope.memberInfo.username + + ' under organization ' + $scope.orgname; + + $timeout(function() { + $scope.ready = true; + }); + + return resp.member; + }); + }; + + // Load the org info and the member info. + loadOrganization(); + loadMemberInfo(); + } +})(); diff --git a/static/js/pages/org-view.js b/static/js/pages/org-view.js new file mode 100644 index 000000000..f1a3fa287 --- /dev/null +++ b/static/js/pages/org-view.js @@ -0,0 +1,99 @@ +(function() { + /** + * Page that displays details about an organization, such as its teams. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('org-view', 'org-view.html', OrgViewCtrl); + }]); + + function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams, CreateService) { + var orgname = $routeParams.orgname; + + $scope.TEAM_PATTERN = TEAM_PATTERN; + $rootScope.title = 'Loading...'; + + $scope.teamRoles = [ + { 'id': 'member', 'title': 'Member', 'kind': 'default' }, + { 'id': 'creator', 'title': 'Creator', 'kind': 'success' }, + { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } + ]; + + $scope.setRole = function(role, teamname) { + var previousRole = $scope.organization.teams[teamname].role; + $scope.organization.teams[teamname].role = role; + + var params = { + 'orgname': orgname, + 'teamname': teamname + }; + + var data = $scope.organization.teams[teamname]; + + ApiService.updateOrganizationTeam(data, params).then(function(resp) { + }, function(resp) { + $scope.organization.teams[teamname].role = previousRole; + $scope.roleError = resp.data || ''; + $('#cannotChangeTeamModal').modal({}); + }); + }; + + $scope.createTeam = function(teamname) { + if (!teamname) { + return; + } + + if ($scope.organization.teams[teamname]) { + $('#team-' + teamname).removeClass('highlight'); + setTimeout(function() { + $('#team-' + teamname).addClass('highlight'); + }, 10); + return; + } + + CreateService.createOrganizationTeam(ApiService, orgname, teamname, function(created) { + $scope.organization.teams[teamname] = created; + }); + }; + + $scope.askDeleteTeam = function(teamname) { + $scope.currentDeleteTeam = teamname; + $('#confirmdeleteModal').modal({}); + }; + + $scope.deleteTeam = function() { + $('#confirmdeleteModal').modal('hide'); + if (!$scope.currentDeleteTeam) { return; } + + var teamname = $scope.currentDeleteTeam; + var params = { + 'orgname': orgname, + 'teamname': teamname + }; + + var errorHandler = ApiService.errorDisplay('Cannot delete team', function() { + $scope.currentDeleteTeam = null; + }); + + ApiService.deleteOrganizationTeam(null, params).then(function() { + delete $scope.organization.teams[teamname]; + $scope.currentDeleteTeam = null; + }, errorHandler); + }; + + var loadOrganization = function() { + $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { + $scope.organization = org; + $rootScope.title = orgname; + $rootScope.description = 'Viewing organization ' + orgname; + + $('.info-icon').popover({ + 'trigger': 'hover', + 'html': true + }); + }); + }; + + // Load the organization. + loadOrganization(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/organizations.js b/static/js/pages/organizations.js new file mode 100644 index 000000000..1158b01bc --- /dev/null +++ b/static/js/pages/organizations.js @@ -0,0 +1,16 @@ +(function() { + /** + * Page which displays the list of organizations of which the user is a member. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('organizations', 'organizations.html', OrgsCtrl, { + 'title': 'View Organizations', + 'description': 'View and manage your organizations' + }); + }]); + + function OrgsCtrl($scope, UserService) { + UserService.updateUserIn($scope); + browserchrome.update(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/plans.js b/static/js/pages/plans.js new file mode 100644 index 000000000..22b73d1bd --- /dev/null +++ b/static/js/pages/plans.js @@ -0,0 +1,38 @@ +(function() { + /** + * The plans/pricing page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('plans', 'plans.html', PlansCtrl, { + 'title': 'Plans and Pricing' + }); + }]); + + function PlansCtrl($scope, $location, UserService, PlanService, $routeParams) { + // Monitor any user changes and place the current user into the scope. + UserService.updateUserIn($scope); + + $scope.signedIn = function() { + $('#signinModal').modal('hide'); + PlanService.handleNotedPlan(); + }; + + $scope.buyNow = function(plan) { + PlanService.notePlan(plan); + if ($scope.user && !$scope.user.anonymous) { + PlanService.handleNotedPlan(); + } else { + $('#signinModal').modal({}); + } + }; + + // Load the list of plans. + PlanService.getPlans(function(plans) { + $scope.plans = plans; + + if ($scope && $routeParams['trial-plan']) { + $scope.buyNow($routeParams['trial-plan']); + } + }, /* include the personal plan */ true); + } +})(); \ No newline at end of file diff --git a/static/js/pages/repo-admin.js b/static/js/pages/repo-admin.js new file mode 100644 index 000000000..d45cf638e --- /dev/null +++ b/static/js/pages/repo-admin.js @@ -0,0 +1,405 @@ +(function() { + /** + * Repository admin/settings page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('repo-admin', 'repo-admin.html', RepoAdminCtrl); + }]); + + function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, TriggerService, $routeParams, + $rootScope, $location, UserService, Config, Features, ExternalNotificationData, UtilService) { + + var namespace = $routeParams.namespace; + var name = $routeParams.name; + + $scope.Features = Features; + $scope.TriggerService = TriggerService; + $scope.KeyService = KeyService; + + $scope.permissions = {'team': [], 'user': [], 'loading': 2}; + $scope.logsShown = 0; + $scope.deleting = false; + + $scope.permissionCache = {}; + $scope.showTriggerSetupCounter = 0; + + $scope.getBadgeFormat = function(format, repo) { + if (!repo) { return; } + + var imageUrl = Config.getUrl('/repository/' + namespace + '/' + name + '/status'); + if (!$scope.repo.is_public) { + imageUrl += '?token=' + $scope.repo.status_token; + } + + var linkUrl = Config.getUrl('/repository/' + namespace + '/' + name); + + switch (format) { + case 'svg': + return imageUrl; + + case 'md': + return '[![Docker Repository on ' + Config.REGISTRY_TITLE_SHORT + '](' + imageUrl + + ' "Docker Repository on ' + Config.REGISTRY_TITLE_SHORT + '")](' + linkUrl + ')'; + + case 'asciidoc': + return 'image:' + imageUrl + '["Docker Repository on ' + Config.REGISTRY_TITLE_SHORT + '", link="' + linkUrl + '"]'; + } + + return ''; + }; + + $scope.buildEntityForPermission = function(name, permission, kind) { + var key = name + ':' + kind; + if ($scope.permissionCache[key]) { + return $scope.permissionCache[key]; + } + + return $scope.permissionCache[key] = { + 'kind': kind, + 'name': name, + 'is_robot': permission.is_robot, + 'is_org_member': permission.is_org_member + }; + }; + + $scope.loadLogs = function() { + $scope.logsShown++; + }; + + $scope.grantRole = function() { + $('#confirmaddoutsideModal').modal('hide'); + var entity = $scope.currentAddEntity; + $scope.addRole(entity.name, 'read', entity.kind, entity.is_org_member) + $scope.currentAddEntity = null; + }; + + $scope.addNewPermission = function(entity) { + // Don't allow duplicates. + if (!entity || !entity.kind || $scope.permissions[entity.kind][entity.name]) { return; } + + if (entity.is_org_member === false) { + $scope.currentAddEntity = entity; + $('#confirmaddoutsideModal').modal('show'); + return; + } + + $scope.addRole(entity.name, 'read', entity.kind); + }; + + $scope.deleteRole = function(entityName, kind) { + var errorHandler = ApiService.errorDisplay('Cannot change permission', function(resp) { + if (resp.status == 409) { + return 'Cannot change permission as you do not have the authority'; + } + }); + + var permissionDelete = Restangular.one(UtilService.getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); + permissionDelete.customDELETE().then(function() { + delete $scope.permissions[kind][entityName]; + }, errorHandler); + }; + + $scope.addRole = function(entityName, role, kind) { + var permission = { + 'role': role, + }; + + var permissionPost = Restangular.one(UtilService.getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); + permissionPost.customPUT(permission).then(function(result) { + $scope.permissions[kind][entityName] = result; + }, ApiService.errorDisplay('Cannot change permission')); + }; + + $scope.roles = [ + { 'id': 'read', 'title': 'Read', 'kind': 'success' }, + { 'id': 'write', 'title': 'Write', 'kind': 'success' }, + { 'id': 'admin', 'title': 'Admin', 'kind': 'primary' } + ]; + + $scope.setRole = function(role, entityName, kind) { + var permission = $scope.permissions[kind][entityName]; + var currentRole = permission.role; + permission.role = role; + + var permissionPut = Restangular.one(UtilService.getRestUrl('repository', namespace, name, 'permissions', kind, entityName)); + permissionPut.customPUT(permission).then(function() {}, function(resp) { + $scope.permissions[kind][entityName] = {'role': currentRole}; + $scope.changePermError = null; + if (resp.status == 409 || resp.data) { + $scope.changePermError = resp.data || ''; + $('#channgechangepermModal').modal({}); + } else { + $('#cannotchangeModal').modal({}); + } + }); + }; + + $scope.newTokenName = null; + + $scope.createToken = function() { + var data = { + 'friendlyName': $scope.newTokenName + }; + + var params = {'repository': namespace + '/' + name}; + ApiService.createToken(data, params).then(function(newToken) { + $scope.newTokenName = null; + $scope.createTokenForm.$setPristine(); + $scope.tokens[newToken.code] = newToken; + }); + }; + + $scope.deleteToken = function(tokenCode) { + var params = { + 'repository': namespace + '/' + name, + 'code': tokenCode + }; + + ApiService.deleteToken(null, params).then(function() { + delete $scope.tokens[tokenCode]; + }); + }; + + $scope.changeTokenAccess = function(tokenCode, newAccess) { + var role = { + 'role': newAccess + }; + + var params = { + 'repository': namespace + '/' + name, + 'code': tokenCode + }; + + ApiService.changeToken(role, params).then(function(updated) { + $scope.tokens[updated.code] = updated; + }); + }; + + $scope.shownTokenCounter = 0; + + $scope.showToken = function(tokenCode) { + $scope.shownToken = $scope.tokens[tokenCode]; + $scope.shownTokenCounter++; + }; + + $scope.askChangeAccess = function(newAccess) { + $('#make' + newAccess + 'Modal').modal({}); + }; + + $scope.changeAccess = function(newAccess) { + $('#make' + newAccess + 'Modal').modal('hide'); + + var visibility = { + 'visibility': newAccess + }; + + var params = { + 'repository': namespace + '/' + name + }; + + ApiService.changeRepoVisibility(visibility, params).then(function() { + $scope.repo.is_public = newAccess == 'public'; + }, function() { + $('#cannotchangeModal').modal({}); + }); + }; + + $scope.askDelete = function() { + $('#confirmdeleteModal').modal({}); + }; + + $scope.deleteRepo = function() { + $('#confirmdeleteModal').modal('hide'); + + var params = { + 'repository': namespace + '/' + name + }; + + $scope.deleting = true; + ApiService.deleteRepository(null, params).then(function() { + $scope.repo = null; + + setTimeout(function() { + document.location = '/repository/'; + }, 1000); + }, function() { + $scope.deleting = true; + $('#cannotchangeModal').modal({}); + }); + }; + + $scope.showNewNotificationCounter = 0; + + $scope.showNewNotificationDialog = function() { + $scope.showNewNotificationCounter++; + }; + + $scope.handleNotificationCreated = function(notification) { + $scope.notifications.push(notification); + }; + + $scope.handleNotificationDeleted = function(notification) { + var index = $.inArray(notification, $scope.notifications); + if (index < 0) { return; } + $scope.notifications.splice(index, 1); + }; + + $scope.loadNotifications = function() { + var params = { + 'repository': namespace + '/' + name + }; + + $scope.notificationsResource = ApiService.listRepoNotificationsAsResource(params).get( + function(resp) { + $scope.notifications = resp.notifications; + return $scope.notifications; + }); + }; + + $scope.showBuild = function(buildInfo) { + $location.path('/repository/' + namespace + '/' + name + '/build'); + $location.search('current', buildInfo.id); + }; + + $scope.loadTriggerBuildHistory = function(trigger) { + trigger.$loadingHistory = true; + + var params = { + 'repository': namespace + '/' + name, + 'trigger_uuid': trigger.id, + 'limit': 3 + }; + + ApiService.listTriggerRecentBuilds(null, params).then(function(resp) { + trigger.$builds = resp['builds']; + trigger.$loadingHistory = false; + }); + }; + + $scope.loadTriggers = function() { + var params = { + 'repository': namespace + '/' + name + }; + + $scope.triggersResource = ApiService.listBuildTriggersAsResource(params).get(function(resp) { + $scope.triggers = resp.triggers; + + // Check to see if we need to setup any trigger. + var newTriggerId = $routeParams.new_trigger; + if (newTriggerId) { + for (var i = 0; i < $scope.triggers.length; ++i) { + var trigger = $scope.triggers[i]; + if (trigger['id'] == newTriggerId && !trigger['is_active']) { + $scope.setupTrigger(trigger); + break; + } + } + } + + return $scope.triggers; + }); + }; + + $scope.setupTrigger = function(trigger) { + $scope.currentSetupTrigger = trigger; + $scope.showTriggerSetupCounter++; + }; + + $scope.cancelSetupTrigger = function(trigger) { + if ($scope.currentSetupTrigger != trigger) { return; } + + $scope.currentSetupTrigger = null; + $scope.deleteTrigger(trigger); + }; + + $scope.showManualBuildDialog = 0; + + $scope.startTrigger = function(trigger, opt_custom) { + var parameters = TriggerService.getRunParameters(trigger.service); + if (parameters.length && !opt_custom) { + $scope.currentStartTrigger = trigger; + $scope.showManualBuildDialog++; + return; + } + + var params = { + 'repository': namespace + '/' + name, + 'trigger_uuid': trigger.id + }; + + ApiService.manuallyStartBuildTrigger(opt_custom || {}, params).then(function(resp) { + var url = '/repository/' + namespace + '/' + name + '/build?current=' + resp['id']; + document.location = url; + }, ApiService.errorDisplay('Could not start build')); + }; + + $scope.deleteTrigger = function(trigger) { + if (!trigger) { return; } + + var params = { + 'repository': namespace + '/' + name, + 'trigger_uuid': trigger.id + }; + + ApiService.deleteBuildTrigger(null, params).then(function(resp) { + $scope.triggers.splice($scope.triggers.indexOf(trigger), 1); + }); + }; + + var fetchTokens = function() { + var params = { + 'repository': namespace + '/' + name + }; + + ApiService.listRepoTokens(null, params).then(function(resp) { + $scope.tokens = resp.tokens; + }, function() { + $scope.tokens = null; + }); + }; + + var fetchPermissions = function(kind) { + var permissionsFetch = Restangular.one('repository/' + namespace + '/' + name + '/permissions/' + kind + '/'); + permissionsFetch.get().then(function(resp) { + $scope.permissions[kind] = resp.permissions; + $scope.permissions['loading']--; + }, function() { + $scope.permissions[kind] = null; + }); + }; + + var fetchRepository = function() { + var params = { + 'repository': namespace + '/' + name + }; + + $scope.repository = ApiService.getRepoAsResource(params).get(function(repo) { + if (!repo.can_admin) { + $rootScope.title = 'Forbidden'; + $scope.accessDenied = true; + return; + } + + $scope.repo = repo; + $rootScope.title = 'Settings - ' + namespace + '/' + name; + $rootScope.description = 'Administrator settings for ' + namespace + '/' + name + + ': Permissions, notifications and other settings'; + + // Fetch all the permissions and token info for the repository. + fetchPermissions('user'); + fetchPermissions('team'); + fetchTokens(); + + $('.info-icon').popover({ + 'trigger': 'hover', + 'html': true + }); + + return $scope.repo; + }); + }; + + // Fetch the repository. + fetchRepository(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/repo-build.js b/static/js/pages/repo-build.js new file mode 100644 index 000000000..3666b9270 --- /dev/null +++ b/static/js/pages/repo-build.js @@ -0,0 +1,281 @@ +(function() { + /** + * Repository Build view page. Displays the status of a repository build. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('repo-build', 'repo-build.html', RepoBuildCtrl); + }]); + + function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $location, $interval, $sanitize, + ansi2html, AngularViewArray, AngularPollChannel) { + var namespace = $routeParams.namespace; + var name = $routeParams.name; + + // Watch for changes to the current parameter. + $scope.$on('$routeUpdate', function(){ + if ($location.search().current) { + $scope.setCurrentBuild($location.search().current, false); + } + }); + + $scope.builds = null; + $scope.pollChannel = null; + $scope.buildDialogShowCounter = 0; + + $scope.showNewBuildDialog = function() { + $scope.buildDialogShowCounter++; + }; + + $scope.handleBuildStarted = function(newBuild) { + if (!$scope.builds) { return; } + + $scope.builds.unshift(newBuild); + $scope.setCurrentBuild(newBuild['id'], true); + }; + + $scope.adjustLogHeight = function() { + var triggerOffset = 0; + if ($scope.currentBuild && $scope.currentBuild.trigger) { + triggerOffset = 85; + } + $('.build-logs').height($(window).height() - 415 - triggerOffset); + }; + + $scope.askRestartBuild = function(build) { + $('#confirmRestartBuildModal').modal({}); + }; + + $scope.askCancelBuild = function(build) { + bootbox.confirm('Are you sure you want to cancel this build?', function(r) { + if (r) { + var params = { + 'repository': namespace + '/' + name, + 'build_uuid': build.id + }; + + ApiService.cancelRepoBuild(null, params).then(function() { + if (!$scope.builds) { return; } + $scope.builds.splice($.inArray(build, $scope.builds), 1); + + if ($scope.builds.length) { + $scope.currentBuild = $scope.builds[0]; + } else { + $scope.currentBuild = null; + } + }, ApiService.errorDisplay('Cannot cancel build')); + } + }); + }; + + $scope.restartBuild = function(build) { + $('#confirmRestartBuildModal').modal('hide'); + + var subdirectory = ''; + if (build['job_config']) { + subdirectory = build['job_config']['build_subdir'] || ''; + } + + var data = { + 'file_id': build['resource_key'], + 'subdirectory': subdirectory, + 'docker_tags': build['job_config']['docker_tags'] + }; + + if (build['pull_robot']) { + data['pull_robot'] = build['pull_robot']['name']; + } + + var params = { + 'repository': namespace + '/' + name + }; + + ApiService.requestRepoBuild(data, params).then(function(newBuild) { + if (!$scope.builds) { return; } + + $scope.builds.unshift(newBuild); + $scope.setCurrentBuild(newBuild['id'], true); + }); + }; + + $scope.hasLogs = function(container) { + return container.logs.hasEntries; + }; + + $scope.setCurrentBuild = function(buildId, opt_updateURL) { + if (!$scope.builds) { return; } + + // Find the build. + for (var i = 0; i < $scope.builds.length; ++i) { + if ($scope.builds[i].id == buildId) { + $scope.setCurrentBuildInternal(i, $scope.builds[i], opt_updateURL); + return; + } + } + }; + + $scope.processANSI = function(message, container) { + var filter = container.logs._filter = (container.logs._filter || ansi2html.create()); + + // Note: order is important here. + var setup = filter.getSetupHtml(); + var stream = filter.addInputToStream(message); + var teardown = filter.getTeardownHtml(); + return setup + stream + teardown; + }; + + $scope.setCurrentBuildInternal = function(index, build, opt_updateURL) { + if (build == $scope.currentBuild) { return; } + + $scope.logEntries = null; + $scope.logStartIndex = null; + $scope.currentParentEntry = null; + + $scope.currentBuild = build; + + if (opt_updateURL) { + if (build) { + $location.search('current', build.id); + } else { + $location.search('current', null); + } + } + + // Timeout needed to ensure the log element has been created + // before its height is adjusted. + setTimeout(function() { + $scope.adjustLogHeight(); + }, 1); + + // Stop any existing polling. + if ($scope.pollChannel) { + $scope.pollChannel.stop(); + } + + // Create a new channel for polling the build status and logs. + var conductStatusAndLogRequest = function(callback) { + getBuildStatusAndLogs(build, callback); + }; + + $scope.pollChannel = AngularPollChannel.create($scope, conductStatusAndLogRequest, 5 * 1000 /* 5s */); + $scope.pollChannel.start(); + }; + + var processLogs = function(logs, startIndex, endIndex) { + if (!$scope.logEntries) { $scope.logEntries = []; } + + // If the start index given is less than that requested, then we've received a larger + // pool of logs, and we need to only consider the new ones. + if (startIndex < $scope.logStartIndex) { + logs = logs.slice($scope.logStartIndex - startIndex); + } + + for (var i = 0; i < logs.length; ++i) { + var entry = logs[i]; + var type = entry['type'] || 'entry'; + if (type == 'command' || type == 'phase' || type == 'error') { + entry['logs'] = AngularViewArray.create(); + entry['index'] = $scope.logStartIndex + i; + + $scope.logEntries.push(entry); + $scope.currentParentEntry = entry; + } else if ($scope.currentParentEntry) { + $scope.currentParentEntry['logs'].push(entry); + } + } + + return endIndex; + }; + + var getBuildStatusAndLogs = function(build, callback) { + var params = { + 'repository': namespace + '/' + name, + 'build_uuid': build.id + }; + + ApiService.getRepoBuildStatus(null, params, true).then(function(resp) { + if (build != $scope.currentBuild) { callback(false); return; } + + // Note: We use extend here rather than replacing as Angular is depending on the + // root build object to remain the same object. + var matchingBuilds = $.grep($scope.builds, function(elem) { + return elem['id'] == resp['id'] + }); + + var currentBuild = matchingBuilds.length > 0 ? matchingBuilds[0] : null; + if (currentBuild) { + currentBuild = $.extend(true, currentBuild, resp); + } else { + currentBuild = resp; + $scope.builds.push(currentBuild); + } + + // Load the updated logs for the build. + var options = { + 'start': $scope.logStartIndex + }; + + ApiService.getRepoBuildLogsAsResource(params, true).withOptions(options).get(function(resp) { + if (build != $scope.currentBuild) { callback(false); return; } + + // Process the logs we've received. + $scope.logStartIndex = processLogs(resp['logs'], resp['start'], resp['total']); + + // If the build status is an error, open the last two log entries. + if (currentBuild['phase'] == 'error' && $scope.logEntries.length > 1) { + var openLogEntries = function(entry) { + if (entry.logs) { + entry.logs.setVisible(true); + } + }; + + openLogEntries($scope.logEntries[$scope.logEntries.length - 2]); + openLogEntries($scope.logEntries[$scope.logEntries.length - 1]); + } + + // If the build phase is an error or a complete, then we mark the channel + // as closed. + callback(currentBuild['phase'] != 'error' && currentBuild['phase'] != 'complete'); + }, function() { + callback(false); + }); + }, function() { + callback(false); + }); + }; + + var fetchRepository = function() { + var params = {'repository': namespace + '/' + name}; + $rootScope.title = 'Loading Repository...'; + $scope.repository = ApiService.getRepoAsResource(params).get(function(repo) { + if (!repo.can_write) { + $rootScope.title = 'Unknown builds'; + $scope.accessDenied = true; + return; + } + + $rootScope.title = 'Repository Builds'; + $scope.repo = repo; + + getBuildInfo(); + }); + }; + + var getBuildInfo = function(repo) { + var params = { + 'repository': namespace + '/' + name + }; + + ApiService.getRepoBuilds(null, params).then(function(resp) { + $scope.builds = resp.builds; + + if ($location.search().current) { + $scope.setCurrentBuild($location.search().current, false); + } else if ($scope.builds.length > 0) { + $scope.setCurrentBuild($scope.builds[0].id, true); + } + }); + }; + + fetchRepository(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/repo-list.js b/static/js/pages/repo-list.js new file mode 100644 index 000000000..354a12895 --- /dev/null +++ b/static/js/pages/repo-list.js @@ -0,0 +1,76 @@ +(function() { + /** + * Repository listing page. Shows all repositories for all visibile namespaces. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('repo-list', 'repo-list.html', RepoListCtrl, { + 'title': 'Repositories', + 'description': 'View and manage Docker repositories' + }); + }]); + + function RepoListCtrl($scope, $sanitize, Restangular, UserService, ApiService) { + $scope.namespace = null; + $scope.page = 1; + $scope.publicPageCount = null; + + // Monitor changes in the user. + UserService.updateUserIn($scope, function() { + loadMyRepos($scope.namespace); + }); + + // Monitor changes in the namespace. + $scope.$watch('namespace', function(namespace) { + loadMyRepos(namespace); + }); + + $scope.movePublicPage = function(increment) { + if ($scope.publicPageCount == null) { + return; + } + + $scope.page += increment; + if ($scope.page < 1) { + $scope.page = 1; + } + + if ($scope.page > $scope.publicPageCount) { + $scope.page = $scope.publicPageCount; + } + + loadPublicRepos(); + }; + + var loadMyRepos = function(namespace) { + if (!$scope.user || $scope.user.anonymous || !namespace) { + return; + } + + var options = {'public': false, 'sort': true, 'namespace': namespace}; + + $scope.user_repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) { + return resp.repositories; + }); + }; + + var loadPublicRepos = function() { + var options = { + 'public': true, + 'private': false, + 'sort': true, + 'limit': 10, + 'page': $scope.page, + 'count': $scope.page == 1 + }; + + $scope.public_repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) { + if (resp.count) { + $scope.publicPageCount = Math.ceil(resp.count / 10); + } + return resp.repositories; + }); + }; + + loadPublicRepos(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/repo-view.js b/static/js/pages/repo-view.js new file mode 100644 index 000000000..5aaa1b225 --- /dev/null +++ b/static/js/pages/repo-view.js @@ -0,0 +1,539 @@ +(function() { + /** + * Repository view page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('repo-view', 'repo-view.html', RepoCtrl); + }]); + + function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiService, $routeParams, $rootScope, $location, $timeout, Config, UtilService) { + $scope.Config = Config; + + var namespace = $routeParams.namespace; + var name = $routeParams.name; + + $scope.pullCommands = []; + $scope.currentPullCommand = null; + + $rootScope.title = 'Loading...'; + + // Watch for the destruction of the scope. + $scope.$on('$destroy', function() { + if ($scope.tree) { + $scope.tree.dispose(); + } + }); + + // Watch for changes to the repository. + $scope.$watch('repo', function() { + if ($scope.tree) { + $timeout(function() { + $scope.tree.notifyResized(); + }); + } + }); + + // Watch for changes to the tag parameter. + $scope.$on('$routeUpdate', function(){ + if ($location.search().tag) { + $scope.setTag($location.search().tag, false); + } else if ($location.search().image) { + $scope.setImage($location.search().image, false); + } else { + $scope.setTag($location.search().tag, false); + } + }); + + // Start scope methods ////////////////////////////////////////// + + $scope.buildDialogShowCounter = 0; + $scope.getFormattedCommand = ImageMetadataService.getFormattedCommand; + + $scope.setCurrentPullCommand = function(pullCommand) { + $scope.currentPullCommand = pullCommand; + }; + + $scope.updatePullCommand = function() { + $scope.pullCommands = []; + + if ($scope.currentTag) { + $scope.pullCommands.push({ + 'title': 'docker pull (Tag ' + $scope.currentTag.name + ')', + 'shortTitle': 'Pull Tag', + 'icon': 'fa-tag', + 'command': 'docker pull ' + Config.getDomain() + '/' + namespace + '/' + name + ':' + $scope.currentTag.name + }); + } + + $scope.pullCommands.push({ + 'title': 'docker pull (Full Repository)', + 'shortTitle': 'Pull Repo', + 'icon': 'fa-code-fork', + 'command': 'docker pull ' + Config.getDomain() + '/' + namespace + '/' + name + }); + + if ($scope.currentTag) { + var squash = 'curl -L -f ' + Config.getHost('ACCOUNTNAME:PASSWORDORTOKEN'); + squash += '/c1/squash/' + namespace + '/' + name + '/' + $scope.currentTag.name; + squash += ' | docker load'; + + $scope.pullCommands.push({ + 'title': 'Squashed image (Tag ' + $scope.currentTag.name + ')', + 'shortTitle': 'Squashed', + 'icon': 'fa-file-archive-o', + 'command': squash, + 'experimental': true + }); + } + + $scope.currentPullCommand = $scope.pullCommands[0]; + }; + + $scope.showNewBuildDialog = function() { + $scope.buildDialogShowCounter++; + }; + + $scope.handleBuildStarted = function(build) { + getBuildInfo($scope.repo); + startBuildInfoTimer($scope.repo); + }; + + $scope.showBuild = function(buildInfo) { + $location.path('/repository/' + namespace + '/' + name + '/build'); + $location.search('current', buildInfo.id); + }; + + $scope.isPushing = function(images) { + if (!images) { return false; } + + var cached = images.__isPushing; + if (cached !== undefined) { + return cached; + } + + return images.__isPushing = $scope.isPushingInternal(images); + }; + + $scope.isPushingInternal = function(images) { + if (!images) { return false; } + + for (var i = 0; i < images.length; ++i) { + if (images[i].uploading) { return true; } + } + + return false; + }; + + $scope.getTooltipCommand = function(image) { + var sanitized = ImageMetadataService.getEscapedFormattedCommand(image); + return '' + sanitized + ''; + }; + + $scope.updateForDescription = function(content) { + $scope.repo.description = content; + $scope.repo.put(); + }; + + $scope.parseDate = function(dateString) { + return Date.parse(dateString); + }; + + $scope.getTimeSince = function(createdTime) { + return moment($scope.parseDate(createdTime)).fromNow(); + }; + + $scope.loadImageChanges = function(image) { + if (!image) { return; } + + var params = {'repository': namespace + '/' + name, 'image_id': image.id}; + $scope.currentImageChangeResource = ApiService.getImageChangesAsResource(params).get(function(ci) { + $scope.currentImageChanges = ci; + }); + }; + + $scope.getMoreCount = function(changes) { + if (!changes) { return 0; } + var addedDisplayed = Math.min(2, changes.added.length); + var removedDisplayed = Math.min(2, changes.removed.length); + var changedDisplayed = Math.min(2, changes.changed.length); + + return (changes.added.length + changes.removed.length + changes.changed.length) - + addedDisplayed - removedDisplayed - changedDisplayed; + }; + + $scope.showAddTag = function(image) { + $scope.toTagImage = image; + $('#addTagModal').modal('show'); + setTimeout(function() { + $('#tagName').focus(); + }, 500); + }; + + $scope.isOwnedTag = function(image, tagName) { + if (!image || !tagName) { return false; } + return image.tags.indexOf(tagName) >= 0; + }; + + $scope.isAnotherImageTag = function(image, tagName) { + if (!image || !tagName) { return false; } + return image.tags.indexOf(tagName) < 0 && $scope.repo.tags[tagName]; + }; + + $scope.askDeleteTag = function(tagName) { + if (!$scope.repo.can_admin) { return; } + + $scope.tagToDelete = tagName; + $('#confirmdeleteTagModal').modal('show'); + }; + + $scope.findImageForTag = function(tag) { + return tag && $scope.imageByDockerId && $scope.imageByDockerId[tag.image_id]; + }; + + $scope.createOrMoveTag = function(image, tagName, opt_invalid) { + if (opt_invalid) { return; } + + $scope.creatingTag = true; + + var params = { + 'repository': $scope.repo.namespace + '/' + $scope.repo.name, + 'tag': tagName + }; + + var data = { + 'image': image.id + }; + + var errorHandler = ApiService.errorDisplay('Cannot create or move tag', function(resp) { + $('#addTagModal').modal('hide'); + }); + + ApiService.changeTagImage(data, params).then(function(resp) { + $scope.creatingTag = false; + loadViewInfo(); + $('#addTagModal').modal('hide'); + }, errorHandler); + }; + + $scope.deleteTag = function(tagName) { + if (!$scope.repo.can_admin) { return; } + + var params = { + 'repository': namespace + '/' + name, + 'tag': tagName + }; + + var errorHandler = ApiService.errorDisplay('Cannot delete tag', function() { + $('#confirmdeleteTagModal').modal('hide'); + $scope.deletingTag = false; + }); + + $scope.deletingTag = true; + + ApiService.deleteFullTag(null, params).then(function() { + loadViewInfo(); + $('#confirmdeleteTagModal').modal('hide'); + $scope.deletingTag = false; + }, errorHandler); + }; + + $scope.getImagesForTagBySize = function(tag) { + var images = []; + forAllTagImages(tag, function(image) { + images.push(image); + }); + + images.sort(function(a, b) { + return b.size - a.size; + }); + + return images; + }; + + $scope.getTotalSize = function(tag) { + var size = 0; + forAllTagImages(tag, function(image) { + size += image.size; + }); + return size; + }; + + $scope.setImage = function(imageId, opt_updateURL) { + if (!$scope.images) { return; } + + var image = null; + for (var i = 0; i < $scope.images.length; ++i) { + var currentImage = $scope.images[i]; + if (currentImage.id == imageId || currentImage.id.substr(0, 12) == imageId) { + image = currentImage; + break; + } + } + + if (!image) { return; } + + $scope.currentTag = null; + $scope.currentImage = image; + $scope.loadImageChanges(image); + if ($scope.tree) { + $scope.tree.setImage(image.id); + } + + if (opt_updateURL) { + $location.search('tag', null); + $location.search('image', imageId.substr(0, 12)); + } + + $scope.updatePullCommand(); + }; + + $scope.setTag = function(tagName, opt_updateURL) { + var repo = $scope.repo; + if (!repo) { return; } + + var proposedTag = repo.tags[tagName]; + if (!proposedTag) { + // We must find a good default. + for (tagName in repo.tags) { + if (!proposedTag || tagName == 'latest') { + proposedTag = repo.tags[tagName]; + } + } + } + + if (proposedTag) { + $scope.currentTag = proposedTag; + $scope.currentImage = null; + + if ($scope.tree) { + $scope.tree.setTag(proposedTag.name); + } + + if (opt_updateURL) { + $location.search('image', null); + $location.search('tag', proposedTag.name); + } + } + + if ($scope.currentTag && !repo.tags[$scope.currentTag.name]) { + $scope.currentTag = null; + $scope.currentImage = null; + } + + $scope.updatePullCommand(); + }; + + $scope.getTagCount = function(repo) { + if (!repo) { return 0; } + var count = 0; + for (var tag in repo.tags) { + ++count; + } + return count; + }; + + $scope.hideTagMenu = function(tagName, clientX, clientY) { + $scope.currentMenuTag = null; + + var tagMenu = $("#tagContextMenu"); + tagMenu.hide(); + }; + + $scope.showTagMenu = function(tagName, clientX, clientY) { + if (!$scope.repo.can_admin) { return; } + + $scope.currentMenuTag = tagName; + + var tagMenu = $("#tagContextMenu"); + tagMenu.css({ + display: "block", + left: clientX, + top: clientY + }); + + tagMenu.on("blur", function() { + setTimeout(function() { + tagMenu.hide(); + }, 100); // Needed to allow clicking on menu items. + }); + + tagMenu.on("click", "a", function() { + setTimeout(function() { + tagMenu.hide(); + }, 100); // Needed to allow clicking on menu items. + }); + + tagMenu[0].focus(); + }; + + var getDefaultTag = function() { + if ($scope.repo === undefined) { + return undefined; + } else if ($scope.repo.tags.hasOwnProperty('latest')) { + return $scope.repo.tags['latest']; + } else { + for (key in $scope.repo.tags) { + return $scope.repo.tags[key]; + } + } + }; + + var forAllTagImages = function(tag, callback) { + if (!tag || !$scope.imageByDockerId) { return; } + + var tag_image = $scope.imageByDockerId[tag.image_id]; + if (!tag_image) { return; } + + // Callback the tag's image itself. + callback(tag_image); + + // Callback any parent images. + if (!tag_image.ancestors) { return; } + var ancestors = tag_image.ancestors.split('/'); + for (var i = 0; i < ancestors.length; ++i) { + var image = $scope.imageByDockerId[ancestors[i]]; + if (image) { + callback(image); + } + } + }; + + var fetchRepository = function() { + var params = {'repository': namespace + '/' + name}; + $rootScope.title = 'Loading Repository...'; + $scope.repository = ApiService.getRepoAsResource(params).get(function(repo) { + // Set the repository object. + $scope.repo = repo; + + // Set the default tag. + $scope.setTag($routeParams.tag); + + // Set the title of the page. + var qualifiedRepoName = namespace + '/' + name; + $rootScope.title = qualifiedRepoName; + var kind = repo.is_public ? 'public' : 'private'; + $rootScope.description = jQuery(UtilService.getFirstMarkdownLineAsText(repo.description)).text() || + 'Visualization of images and tags for ' + kind + ' Docker repository: ' + qualifiedRepoName; + + // Load the builds for this repository. If none are active it will cancel the poll. + startBuildInfoTimer(repo); + }); + }; + + var startBuildInfoTimer = function(repo) { + if ($scope.interval) { return; } + + getBuildInfo(repo); + $scope.interval = setInterval(function() { + $scope.$apply(function() { getBuildInfo(repo); }); + }, 5000); + + $scope.$on("$destroy", function() { + cancelBuildInfoTimer(); + }); + }; + + var cancelBuildInfoTimer = function() { + if ($scope.interval) { + clearInterval($scope.interval); + } + }; + + var getBuildInfo = function(repo) { + var params = { + 'repository': repo.namespace + '/' + repo.name + }; + + ApiService.getRepoBuilds(null, params, true).then(function(resp) { + // Build a filtered list of the builds that are currently running. + var runningBuilds = []; + for (var i = 0; i < resp.builds.length; ++i) { + var build = resp.builds[i]; + if (build['phase'] != 'complete' && build['phase'] != 'error') { + runningBuilds.push(build); + } + } + + var existingBuilds = $scope.runningBuilds || []; + $scope.runningBuilds = runningBuilds; + $scope.buildHistory = resp.builds; + + if (!runningBuilds.length) { + // Cancel the build timer. + cancelBuildInfoTimer(); + + // Mark the repo as no longer building. + $scope.repo.is_building = false; + + // Reload the repo information if all of the builds recently finished. + if (existingBuilds.length > 0) { + loadViewInfo(); + } + } + }); + }; + + var listImages = function() { + var params = {'repository': namespace + '/' + name}; + $scope.imageHistory = ApiService.listRepositoryImagesAsResource(params).get(function(resp) { + $scope.images = resp.images; + $scope.specificImages = []; + + // Build various images for quick lookup of images. + $scope.imageByDockerId = {}; + for (var i = 0; i < $scope.images.length; ++i) { + var currentImage = $scope.images[i]; + $scope.imageByDockerId[currentImage.id] = currentImage; + } + + // Dispose of any existing tree. + if ($scope.tree) { + $scope.tree.dispose(); + } + + // Create the new tree. + var tree = new ImageHistoryTree(namespace, name, resp.images, + UtilService.getFirstMarkdownLineAsText, $scope.getTimeSince, ImageMetadataService.getEscapedFormattedCommand); + + $scope.tree = tree.draw('image-history-container'); + if ($scope.tree) { + // If we already have a tag, use it + if ($scope.currentTag) { + $scope.tree.setTag($scope.currentTag.name); + } + + // Listen for changes to the selected tag and image in the tree. + $($scope.tree).bind('tagChanged', function(e) { + $scope.$apply(function() { $scope.setTag(e.tag, true); }); + }); + + $($scope.tree).bind('imageChanged', function(e) { + $scope.$apply(function() { $scope.setImage(e.image.id, true); }); + }); + + $($scope.tree).bind('showTagMenu', function(e) { + $scope.$apply(function() { $scope.showTagMenu(e.tag, e.clientX, e.clientY); }); + }); + + $($scope.tree).bind('hideTagMenu', function(e) { + $scope.$apply(function() { $scope.hideTagMenu(); }); + }); + } + + if ($routeParams.image) { + $scope.setImage($routeParams.image); + } + + return resp.images; + }); + }; + + var loadViewInfo = function() { + fetchRepository(); + listImages(); + }; + + // Fetch the repository itself as well as the image history. + loadViewInfo(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/security.js b/static/js/pages/security.js new file mode 100644 index 000000000..133410dd9 --- /dev/null +++ b/static/js/pages/security.js @@ -0,0 +1,10 @@ +(function() { + /** + * Security page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('security', 'security.html', null, { + 'title': 'Security' + }); + }]); +}()) \ No newline at end of file diff --git a/static/js/pages/setup.js b/static/js/pages/setup.js new file mode 100644 index 000000000..431811ff4 --- /dev/null +++ b/static/js/pages/setup.js @@ -0,0 +1,299 @@ +(function() { + /** + * The Setup page provides a nice GUI walkthrough experience for setting up the Enterprise + * Registry. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('setup', 'setup.html', SetupCtrl, + { + 'newLayout': true, + 'title': 'Enterprise Registry Setup' + }, + + // Note: This page has already been converted, but also needs to be available in the old layout + ['layout', 'old-layout']) + }]); + + function SetupCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, CoreDialog) { + if (!Features.SUPER_USERS) { + return; + } + + $scope.HOSTNAME_REGEX = '^[a-zA-Z-0-9\.]+(:[0-9]+)?$'; + + $scope.validateHostname = function(hostname) { + if (hostname.indexOf('127.0.0.1') == 0 || hostname.indexOf('localhost') == 0) { + return 'Please specify a non-localhost hostname. "localhost" will refer to the container, not your machine.' + } + + return null; + }; + + // Note: The values of the enumeration are important for isStepFamily. For example, + // *all* states under the "configuring db" family must start with "config-db". + $scope.States = { + // Loading the state of the product. + 'LOADING': 'loading', + + // The configuration directory is missing. + 'MISSING_CONFIG_DIR': 'missing-config-dir', + + // The config.yaml exists but it is invalid. + 'INVALID_CONFIG': 'config-invalid', + + // DB is being configured. + 'CONFIG_DB': 'config-db', + + // DB information is being validated. + 'VALIDATING_DB': 'config-db-validating', + + // DB information is being saved to the config. + 'SAVING_DB': 'config-db-saving', + + // A validation error occurred with the database. + 'DB_ERROR': 'config-db-error', + + // Database is being setup. + 'DB_SETUP': 'setup-db', + + // Database setup has succeeded. + 'DB_SETUP_SUCCESS': 'setup-db-success', + + // An error occurred when setting up the database. + 'DB_SETUP_ERROR': 'setup-db-error', + + // The container is being restarted for the database changes. + 'DB_RESTARTING': 'setup-db-restarting', + + // A superuser is being configured. + 'CREATE_SUPERUSER': 'create-superuser', + + // The superuser is being created. + 'CREATING_SUPERUSER': 'create-superuser-creating', + + // An error occurred when setting up the superuser. + 'SUPERUSER_ERROR': 'create-superuser-error', + + // The superuser was created successfully. + 'SUPERUSER_CREATED': 'create-superuser-created', + + // General configuration is being setup. + 'CONFIG': 'config', + + // The configuration is fully valid. + 'VALID_CONFIG': 'valid-config', + + // The container is being restarted for the configuration changes. + 'CONFIG_RESTARTING': 'config-restarting', + + // The product is ready for use. + 'READY': 'ready' + } + + $scope.csrf_token = window.__token; + $scope.currentStep = $scope.States.LOADING; + $scope.errors = {}; + $scope.stepProgress = []; + $scope.hasSSL = false; + $scope.hostname = null; + + $scope.$watch('currentStep', function(currentStep) { + $scope.stepProgress = $scope.getProgress(currentStep); + + switch (currentStep) { + case $scope.States.CONFIG: + $('#setupModal').modal('hide'); + break; + + case $scope.States.MISSING_CONFIG_DIR: + $scope.showMissingConfigDialog(); + break; + + case $scope.States.INVALID_CONFIG: + $scope.showInvalidConfigDialog(); + break; + + case $scope.States.DB_SETUP: + $scope.performDatabaseSetup(); + // Fall-through. + + case $scope.States.CREATE_SUPERUSER: + case $scope.States.DB_RESTARTING: + case $scope.States.CONFIG_DB: + case $scope.States.VALID_CONFIG: + case $scope.States.READY: + $('#setupModal').modal({ + keyboard: false, + backdrop: 'static' + }); + break; + } + }); + + $scope.restartContainer = function(state) { + $scope.currentStep = state; + ContainerService.restartContainer(function() { + $scope.checkStatus() + }); + }; + + $scope.showSuperuserPanel = function() { + $('#setupModal').modal('hide'); + var prefix = $scope.hasSSL ? 'https' : 'http'; + var hostname = $scope.hostname; + window.location = prefix + '://' + hostname + '/superuser'; + }; + + $scope.configurationSaved = function(config) { + $scope.hasSSL = config['PREFERRED_URL_SCHEME'] == 'https'; + $scope.hostname = config['SERVER_HOSTNAME']; + $scope.currentStep = $scope.States.VALID_CONFIG; + }; + + $scope.getProgress = function(step) { + var isStep = $scope.isStep; + var isStepFamily = $scope.isStepFamily; + var States = $scope.States; + + return [ + isStepFamily(step, States.CONFIG_DB), + isStepFamily(step, States.DB_SETUP), + isStep(step, States.DB_RESTARTING), + isStepFamily(step, States.CREATE_SUPERUSER), + isStep(step, States.CONFIG), + isStep(step, States.VALID_CONFIG), + isStep(step, States.CONFIG_RESTARTING), + isStep(step, States.READY) + ]; + }; + + $scope.isStepFamily = function(step, family) { + if (!step) { return false; } + return step.indexOf(family) == 0; + }; + + $scope.isStep = function(step) { + for (var i = 1; i < arguments.length; ++i) { + if (arguments[i] == step) { + return true; + } + } + return false; + }; + + $scope.showInvalidConfigDialog = function() { + var message = "The config.yaml file found in conf/stack could not be parsed." + var title = "Invalid configuration file"; + CoreDialog.fatal(title, message); + }; + + + $scope.showMissingConfigDialog = function() { + var message = "A volume should be mounted into the container at /conf/stack: " + + "

docker run -v /path/to/config:/conf/stack
" + + "
Once fixed, restart the container. For more information, " + + "" + + "Read the Setup Guide" + + var title = "Missing configuration volume"; + CoreDialog.fatal(title, message); + }; + + $scope.parseDbUri = function(value) { + if (!value) { return null; } + + // Format: mysql+pymysql://:@/ + var uri = URI(value); + return { + 'kind': uri.protocol(), + 'username': uri.username(), + 'password': uri.password(), + 'server': uri.host(), + 'database': uri.path() ? uri.path().substr(1) : '' + }; + }; + + $scope.serializeDbUri = function(fields) { + if (!fields['server']) { return ''; } + + try { + if (!fields['server']) { return ''; } + if (!fields['database']) { return ''; } + + var uri = URI(); + uri = uri && uri.host(fields['server']); + uri = uri && uri.protocol(fields['kind']); + uri = uri && uri.username(fields['username']); + uri = uri && uri.password(fields['password']); + uri = uri && uri.path('/' + (fields['database'] || '')); + uri = uri && uri.toString(); + } catch (ex) { + return ''; + } + + return uri; + }; + + $scope.createSuperUser = function() { + $scope.currentStep = $scope.States.CREATING_SUPERUSER; + ApiService.scCreateInitialSuperuser($scope.superUser, null).then(function(resp) { + UserService.load(); + $scope.checkStatus(); + }, function(resp) { + $scope.currentStep = $scope.States.SUPERUSER_ERROR; + $scope.errors.SuperuserCreationError = ApiService.getErrorMessage(resp, 'Could not create superuser'); + }); + }; + + $scope.performDatabaseSetup = function() { + $scope.currentStep = $scope.States.DB_SETUP; + ApiService.scSetupDatabase(null, null).then(function(resp) { + if (resp['error']) { + $scope.currentStep = $scope.States.DB_SETUP_ERROR; + $scope.errors.DatabaseSetupError = resp['error']; + } else { + $scope.currentStep = $scope.States.DB_SETUP_SUCCESS; + } + }, ApiService.errorDisplay('Could not setup database. Please report this to support.')) + }; + + $scope.validateDatabase = function() { + $scope.currentStep = $scope.States.VALIDATING_DB; + $scope.databaseInvalid = null; + + var data = { + 'config': { + 'DB_URI': $scope.databaseUri + }, + 'hostname': window.location.host + }; + + var params = { + 'service': 'database' + }; + + ApiService.scValidateConfig(data, params).then(function(resp) { + var status = resp.status; + + if (status) { + $scope.currentStep = $scope.States.SAVING_DB; + ApiService.scUpdateConfig(data, null).then(function(resp) { + $scope.checkStatus(); + }, ApiService.errorDisplay('Cannot update config. Please report this to support')); + } else { + $scope.currentStep = $scope.States.DB_ERROR; + $scope.errors.DatabaseValidationError = resp.reason; + } + }, ApiService.errorDisplay('Cannot validate database. Please report this to support')); + }; + + $scope.checkStatus = function() { + ContainerService.checkStatus(function(resp) { + $scope.currentStep = resp['status']; + }, $scope.hasSSL); + }; + + // Load the initial status. + $scope.checkStatus(); + }; +})(); \ No newline at end of file diff --git a/static/js/pages/signin.js b/static/js/pages/signin.js new file mode 100644 index 000000000..12b89e116 --- /dev/null +++ b/static/js/pages/signin.js @@ -0,0 +1,14 @@ +(function() { + /** + * Sign in page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('signin', 'signin.html', SignInCtrl, { + 'title': 'Sign In' + }); + }]); + + function SignInCtrl($scope, $location) { + $scope.redirectUrl = '/'; + } +})(); diff --git a/static/js/pages/superuser.js b/static/js/pages/superuser.js new file mode 100644 index 000000000..1d3fc7dde --- /dev/null +++ b/static/js/pages/superuser.js @@ -0,0 +1,245 @@ +(function() { + /** + * The superuser admin page provides a new management UI for the Enterprise Registry. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('superuser', 'super-user.html', SuperuserCtrl, + { + 'newLayout': true, + 'title': 'Enterprise Registry Management' + }, + + // Note: This page has already been converted, but also needs to be available in the old layout + ['layout', 'old-layout']) + }]); + + function SuperuserCtrl($scope, $timeout, ApiService, Features, UserService, ContainerService, AngularPollChannel, CoreDialog) { + if (!Features.SUPER_USERS) { + return; + } + + // Monitor any user changes and place the current user into the scope. + UserService.updateUserIn($scope); + + $scope.configStatus = null; + $scope.requiresRestart = null; + $scope.logsCounter = 0; + $scope.newUser = {}; + $scope.createdUser = null; + $scope.systemUsage = null; + $scope.debugServices = null; + $scope.debugLogs = null; + $scope.pollChannel = null; + $scope.logsScrolled = false; + $scope.csrf_token = encodeURIComponent(window.__token); + $scope.dashboardActive = false; + + $scope.setDashboardActive = function(active) { + $scope.dashboardActive = active; + }; + + $scope.configurationSaved = function() { + $scope.requiresRestart = true; + }; + + $scope.showCreateUser = function() { + $scope.createdUser = null; + $('#createUserModal').modal('show'); + }; + + $scope.viewSystemLogs = function(service) { + if ($scope.pollChannel) { + $scope.pollChannel.stop(); + } + + $scope.debugService = service; + $scope.debugLogs = null; + + $scope.pollChannel = AngularPollChannel.create($scope, $scope.loadServiceLogs, 2 * 1000 /* 2s */); + $scope.pollChannel.start(); + }; + + $scope.loadServiceLogs = function(callback) { + if (!$scope.debugService) { return; } + + var params = { + 'service': $scope.debugService + }; + + var errorHandler = ApiService.errorDisplay('Cannot load system logs. Please contact support.', + function() { + callback(false); + }) + + ApiService.getSystemLogs(null, params, /* background */true).then(function(resp) { + $scope.debugLogs = resp['logs']; + callback(true); + }, errorHandler); + }; + + $scope.loadDebugServices = function() { + if ($scope.pollChannel) { + $scope.pollChannel.stop(); + } + + $scope.debugService = null; + + ApiService.listSystemLogServices().then(function(resp) { + $scope.debugServices = resp['services']; + }, ApiService.errorDisplay('Cannot load system logs. Please contact support.')) + }; + + $scope.getUsage = function() { + if ($scope.systemUsage) { return; } + + ApiService.getSystemUsage().then(function(resp) { + $scope.systemUsage = resp; + }, ApiService.errorDisplay('Cannot load system usage. Please contact support.')) + } + + $scope.loadUsageLogs = function() { + $scope.logsCounter++; + }; + + $scope.loadUsers = function() { + if ($scope.users) { + return; + } + + $scope.loadUsersInternal(); + }; + + $scope.loadUsersInternal = function() { + ApiService.listAllUsers().then(function(resp) { + $scope.users = resp['users']; + $scope.showInterface = true; + }, function(resp) { + $scope.users = []; + $scope.usersError = resp['data']['message'] || resp['data']['error_description']; + }); + }; + + $scope.showChangePassword = function(user) { + $scope.userToChange = user; + $('#changePasswordModal').modal({}); + }; + + $scope.createUser = function() { + $scope.creatingUser = true; + $scope.createdUser = null; + + var errorHandler = ApiService.errorDisplay('Cannot create user', function() { + $scope.creatingUser = false; + $('#createUserModal').modal('hide'); + }); + + ApiService.createInstallUser($scope.newUser, null).then(function(resp) { + $scope.creatingUser = false; + $scope.newUser = {}; + $scope.createdUser = resp; + $scope.loadUsersInternal(); + }, errorHandler) + }; + + $scope.showDeleteUser = function(user) { + if (user.username == UserService.currentUser().username) { + bootbox.dialog({ + "message": 'Cannot delete yourself!', + "title": "Cannot delete user", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + return; + } + + $scope.userToDelete = user; + $('#confirmDeleteUserModal').modal({}); + }; + + $scope.changeUserPassword = function(user) { + $('#changePasswordModal').modal('hide'); + + var params = { + 'username': user.username + }; + + var data = { + 'password': user.password + }; + + ApiService.changeInstallUser(data, params).then(function(resp) { + $scope.loadUsersInternal(); + }, ApiService.errorDisplay('Could not change user')); + }; + + $scope.deleteUser = function(user) { + $('#confirmDeleteUserModal').modal('hide'); + + var params = { + 'username': user.username + }; + + ApiService.deleteInstallUser(null, params).then(function(resp) { + $scope.loadUsersInternal(); + }, ApiService.errorDisplay('Cannot delete user')); + }; + + $scope.sendRecoveryEmail = function(user) { + var params = { + 'username': user.username + }; + + ApiService.sendInstallUserRecoveryEmail(null, params).then(function(resp) { + bootbox.dialog({ + "message": "A recovery email has been sent to " + resp['email'], + "title": "Recovery email sent", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + + }, ApiService.errorDisplay('Cannot send recovery email')) + }; + + $scope.restartContainer = function() { + $('#restartingContainerModal').modal({ + keyboard: false, + backdrop: 'static' + }); + + ContainerService.restartContainer(function() { + $scope.checkStatus() + }); + }; + + $scope.checkStatus = function() { + ContainerService.checkStatus(function(resp) { + $('#restartingContainerModal').modal('hide'); + $scope.configStatus = resp['status']; + $scope.requiresRestart = resp['requires_restart']; + + if ($scope.configStatus == 'ready') { + $scope.loadUsers(); + } else { + var message = "Installation of this product has not yet been completed." + + "

Please read the " + + "" + + "Setup Guide" + + var title = "Installation Incomplete"; + CoreDialog.fatal(title, message); + } + }); + }; + + // Load the initial status. + $scope.checkStatus(); + } +}()); \ No newline at end of file diff --git a/static/js/pages/team-view.js b/static/js/pages/team-view.js new file mode 100644 index 000000000..ecbf59749 --- /dev/null +++ b/static/js/pages/team-view.js @@ -0,0 +1,167 @@ +(function() { + /** + * Page to view the members of a team and add/remove them. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('team-view', 'team-view.html', TeamViewCtrl); + }]); + + function TeamViewCtrl($rootScope, $scope, $timeout, Features, Restangular, ApiService, $routeParams) { + var teamname = $routeParams.teamname; + var orgname = $routeParams.orgname; + + $scope.orgname = orgname; + $scope.teamname = teamname; + $scope.addingMember = false; + $scope.memberMap = null; + $scope.allowEmail = Features.MAILING; + + $rootScope.title = 'Loading...'; + + $scope.filterFunction = function(invited, robots) { + return function(item) { + // Note: The !! is needed because is_robot will be undefined for invites. + var robot_check = (!!item.is_robot == robots); + return robot_check && item.invited == invited; + }; + }; + + $scope.inviteEmail = function(email) { + if (!email || $scope.memberMap[email]) { return; } + + $scope.addingMember = true; + + var params = { + 'orgname': orgname, + 'teamname': teamname, + 'email': email + }; + + var errorHandler = ApiService.errorDisplay('Cannot invite team member', function() { + $scope.addingMember = false; + }); + + ApiService.inviteTeamMemberEmail(null, params).then(function(resp) { + $scope.members.push(resp); + $scope.memberMap[resp.email] = resp; + $scope.addingMember = false; + }, errorHandler); + }; + + $scope.addNewMember = function(member) { + if (!member || $scope.memberMap[member.name]) { return; } + + var params = { + 'orgname': orgname, + 'teamname': teamname, + 'membername': member.name + }; + + var errorHandler = ApiService.errorDisplay('Cannot add team member', function() { + $scope.addingMember = false; + }); + + $scope.addingMember = true; + ApiService.updateOrganizationTeamMember(null, params).then(function(resp) { + $scope.members.push(resp); + $scope.memberMap[resp.name] = resp; + $scope.addingMember = false; + }, errorHandler); + }; + + $scope.revokeInvite = function(inviteInfo) { + if (inviteInfo.kind == 'invite') { + // E-mail invite. + $scope.revokeEmailInvite(inviteInfo.email); + } else { + // User invite. + $scope.removeMember(inviteInfo.name); + } + }; + + $scope.revokeEmailInvite = function(email) { + var params = { + 'orgname': orgname, + 'teamname': teamname, + 'email': email + }; + + ApiService.deleteTeamMemberEmailInvite(null, params).then(function(resp) { + if (!$scope.memberMap[email]) { return; } + var index = $.inArray($scope.memberMap[email], $scope.members); + $scope.members.splice(index, 1); + delete $scope.memberMap[email]; + }, ApiService.errorDisplay('Cannot revoke team invite')); + }; + + $scope.removeMember = function(username) { + var params = { + 'orgname': orgname, + 'teamname': teamname, + 'membername': username + }; + + ApiService.deleteOrganizationTeamMember(null, params).then(function(resp) { + if (!$scope.memberMap[username]) { return; } + var index = $.inArray($scope.memberMap[username], $scope.members); + $scope.members.splice(index, 1); + delete $scope.memberMap[username]; + }, ApiService.errorDisplay('Cannot remove team member')); + }; + + $scope.updateForDescription = function(content) { + $scope.organization.teams[teamname].description = content; + + var params = { + 'orgname': orgname, + 'teamname': teamname + }; + + var teaminfo = $scope.organization.teams[teamname]; + ApiService.updateOrganizationTeam(teaminfo, params).then(function(resp) { + }, function() { + $('#cannotChangeTeamModal').modal({}); + }); + }; + + var loadOrganization = function() { + $scope.orgResource = ApiService.getOrganizationAsResource({'orgname': orgname}).get(function(org) { + $scope.organization = org; + $scope.team = $scope.organization.teams[teamname]; + $rootScope.title = teamname + ' (' + $scope.orgname + ')'; + $rootScope.description = 'Team management page for team ' + teamname + ' under organization ' + $scope.orgname; + loadMembers(); + return org; + }); + }; + + var loadMembers = function() { + var params = { + 'orgname': orgname, + 'teamname': teamname, + 'includePending': true + }; + + $scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) { + $scope.members = resp.members; + $scope.canEditMembers = resp.can_edit; + + $('.info-icon').popover({ + 'trigger': 'hover', + 'html': true + }); + + $scope.memberMap = {}; + for (var i = 0; i < $scope.members.length; ++i) { + var current = $scope.members[i]; + $scope.memberMap[current.name || current.email] = current; + } + + return resp.members; + }); + }; + + // Load the organization. + loadOrganization(); + } +})(); \ No newline at end of file diff --git a/static/js/pages/tour.js b/static/js/pages/tour.js new file mode 100644 index 000000000..47ca8ea4c --- /dev/null +++ b/static/js/pages/tour.js @@ -0,0 +1,15 @@ +(function() { + /** + * The site tour page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('tour', 'tour.html', TourCtrl, { + 'title': 'Feature Tour', + 'description': 'Take a tour of Quay.io\'s features' + }); + }]); + + function TourCtrl($scope, $location) { + $scope.kind = $location.path().substring('/tour/'.length); + } +})(); \ No newline at end of file diff --git a/static/js/pages/tutorial.js b/static/js/pages/tutorial.js new file mode 100644 index 000000000..d5661c73b --- /dev/null +++ b/static/js/pages/tutorial.js @@ -0,0 +1,161 @@ +(function() { + /** + * Interactive tutorial page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('tutorial', 'tutorial.html', TutorialCtrl, { + 'title': 'Tutorial', + 'description': 'Basic tutorial on using Docker with Quay.io' + }); + }]); + + function TutorialCtrl($scope, AngularTour, AngularTourSignals, UserService, Config) { + // Default to showing sudo on all commands if on linux. + var showSudo = navigator.appVersion.indexOf("Linux") != -1; + + $scope.tour = { + 'title': Config.REGISTRY_TITLE_SHORT + ' Tutorial', + 'initialScope': { + 'showSudo': showSudo, + 'domainName': Config.getDomain() + }, + 'steps': [ + { + 'title': 'Welcome to the ' + Config.REGISTRY_TITLE_SHORT + ' tutorial!', + 'templateUrl': '/static/tutorial/welcome.html' + }, + { + 'title': 'Sign in to get started', + 'templateUrl': '/static/tutorial/signup.html', + 'signal': function($tourScope) { + var user = UserService.currentUser(); + $tourScope.username = user.username; + $tourScope.email = user.email; + $tourScope.inOrganization = user.organizations && user.organizations.length > 0; + return !user.anonymous; + } + }, + { + 'title': 'Step 1: Login to ' + Config.REGISTRY_TITLE, + 'templateUrl': '/static/tutorial/docker-login.html', + 'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli', + function(message) { + return message['data']['action'] == 'login'; + }), + 'waitMessage': "Waiting for docker login", + 'skipTitle': "I'm already logged in", + 'mixpanelEvent': 'tutorial_start' + }, + { + 'title': 'Step 2: Create a new container', + 'templateUrl': '/static/tutorial/create-container.html' + }, + { + 'title': 'Step 3: Create a new image', + 'templateUrl': '/static/tutorial/create-image.html' + }, + { + 'title': 'Step 4: Push the image to ' + Config.REGISTRY_TITLE, + 'templateUrl': '/static/tutorial/push-image.html', + 'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli', + function(message, tourScope) { + var pushing = message['data']['action'] == 'push_repo'; + if (pushing) { + tourScope.repoName = message['data']['repository']; + } + return pushing; + }), + 'waitMessage': "Waiting for repository push to begin", + 'mixpanelEvent': 'tutorial_wait_for_push' + }, + { + 'title': 'Push in progress', + 'templateUrl': '/static/tutorial/pushing.html', + 'signal': AngularTourSignals.serverEvent('/realtime/user/subscribe?events=docker-cli', + function(message, tourScope) { + return message['data']['action'] == 'pushed_repo'; + }), + 'waitMessage': "Waiting for repository push to complete" + }, + { + 'title': 'Step 5: View the repository on ' + Config.REGISTRY_TITLE, + 'templateUrl': '/static/tutorial/view-repo.html', + 'signal': AngularTourSignals.matchesLocation('/repository/'), + 'overlayable': true, + 'mixpanelEvent': 'tutorial_push_complete' + }, + { + 'templateUrl': '/static/tutorial/view-repo.html', + 'signal': AngularTourSignals.matchesLocation('/repository/'), + 'overlayable': true + }, + { + 'templateUrl': '/static/tutorial/waiting-repo-list.html', + 'signal': AngularTourSignals.elementAvaliable('*[data-repo="{{username}}/{{repoName}}"]'), + 'overlayable': true + }, + { + 'templateUrl': '/static/tutorial/repo-list.html', + 'signal': AngularTourSignals.matchesLocation('/repository/{{username}}/{{repoName}}'), + 'element': '*[data-repo="{{username}}/{{repoName}}"]', + 'overlayable': true + }, + { + 'title': 'Repository View', + 'content': 'This is the repository view page. It displays all the primary information about your repository.', + 'overlayable': true, + 'mixpanelEvent': 'tutorial_view_repo' + }, + { + 'title': 'Image History', + 'content': 'The tree displays the full history of your repository, including all its tag. ' + + 'You can click on a tag or image to see its information.', + 'element': '#image-history-container', + 'overlayable': true + }, + { + 'title': 'Tag/Image Information', + 'content': 'This panel displays information about the currently selected tag or image', + 'element': '#side-panel', + 'overlayable': true + }, + { + 'title': 'Select tag or image', + 'content': 'You can select a tag or image by clicking on this dropdown', + 'element': '#side-panel-dropdown', + 'overlayable': true + }, + { + 'content': 'To view the admin settings for the repository, click on the gear', + 'element': '#admin-cog', + 'signal': AngularTourSignals.matchesLocation('/repository/{{username}}/{{repoName}}/admin'), + 'overlayable': true + }, + { + 'title': 'Repository Admin', + 'content': "The repository admin panel allows for modification of a repository's permissions, notifications, visibility and other settings", + 'overlayable': true, + 'mixpanelEvent': 'tutorial_view_admin' + }, + { + 'title': 'Permissions', + 'templateUrl': '/static/tutorial/permissions.html', + 'overlayable': true, + 'element': '#permissions' + }, + { + 'title': 'Adding a permission', + 'content': 'To add an additional permission, enter a username or robot account name into the autocomplete ' + + 'or hit the dropdown arrow to manage robot accounts', + 'overlayable': true, + 'element': '#add-entity-permission' + }, + { + 'templateUrl': '/static/tutorial/done.html', + 'overlayable': true, + 'mixpanelEvent': 'tutorial_complete' + } + ] + }; + } +})(); \ No newline at end of file diff --git a/static/js/pages/user-admin.js b/static/js/pages/user-admin.js new file mode 100644 index 000000000..6af34d264 --- /dev/null +++ b/static/js/pages/user-admin.js @@ -0,0 +1,211 @@ +(function() { + /** + * User admin/settings page. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('user-admin', 'user-admin.html', UserAdminCtrl, { + 'title': 'User Settings' + }); + }]); + + function UserAdminCtrl($scope, $timeout, $location, ApiService, PlanService, UserService, CookieService, KeyService, + $routeParams, $http, UIService, Features, Config) { + $scope.Features = Features; + + if ($routeParams['migrate']) { + $('#migrateTab').tab('show') + } + + UserService.updateUserIn($scope, function(user) { + $scope.cuser = jQuery.extend({}, user); + + if ($scope.cuser.logins) { + for (var i = 0; i < $scope.cuser.logins.length; i++) { + var login = $scope.cuser.logins[i]; + login.metadata = login.metadata || {}; + + if (login.service == 'github') { + $scope.hasGithubLogin = true; + $scope.githubLogin = login.metadata['service_username']; + $scope.githubEndpoint = KeyService['githubEndpoint']; + } + + if (login.service == 'google') { + $scope.hasGoogleLogin = true; + $scope.googleLogin = login.metadata['service_username']; + } + } + } + }); + + $scope.readyForPlan = function() { + // Show the subscribe dialog if a plan was requested. + return $routeParams['plan']; + }; + + $scope.loading = true; + $scope.updatingUser = false; + $scope.changePasswordSuccess = false; + $scope.changeEmailSent = false; + $scope.convertStep = 0; + $scope.org = {}; + $scope.githubRedirectUri = KeyService.githubRedirectUri; + $scope.authorizedApps = null; + + $scope.logsShown = 0; + $scope.invoicesShown = 0; + + $scope.USER_PATTERN = USER_PATTERN; + + $scope.loadAuthedApps = function() { + if ($scope.authorizedApps) { return; } + + ApiService.listUserAuthorizations().then(function(resp) { + $scope.authorizedApps = resp['authorizations']; + }); + }; + + $scope.deleteAccess = function(accessTokenInfo) { + var params = { + 'access_token_uuid': accessTokenInfo['uuid'] + }; + + ApiService.deleteUserAuthorization(null, params).then(function(resp) { + $scope.authorizedApps.splice($scope.authorizedApps.indexOf(accessTokenInfo), 1); + }, ApiService.errorDisplay('Could not revoke authorization')); + }; + + $scope.loadLogs = function() { + if (!$scope.hasPaidBusinessPlan) { return; } + $scope.logsShown++; + }; + + $scope.loadInvoices = function() { + $scope.invoicesShown++; + }; + + $scope.planChanged = function(plan) { + $scope.hasPaidPlan = plan && plan.price > 0; + $scope.hasPaidBusinessPlan = PlanService.isOrgCompatible(plan) && plan.price > 0; + }; + + $scope.showConvertForm = function() { + if (Features.BILLING) { + PlanService.getMatchingBusinessPlan(function(plan) { + $scope.org.plan = plan; + }); + + PlanService.getPlans(function(plans) { + $scope.orgPlans = plans; + }); + } + + $scope.convertStep = 1; + }; + + $scope.convertToOrg = function() { + $('#reallyconvertModal').modal({}); + }; + + $scope.reallyConvert = function() { + if (Config.AUTHENTICATION_TYPE != 'Database') { return; } + + $scope.loading = true; + + var data = { + 'adminUser': $scope.org.adminUser, + 'adminPassword': $scope.org.adminPassword, + 'plan': $scope.org.plan ? $scope.org.plan.stripeId : '' + }; + + ApiService.convertUserToOrganization(data).then(function(resp) { + CookieService.putPermanent('quay.namespace', $scope.cuser.username); + UserService.load(); + $location.path('/'); + }, function(resp) { + $scope.loading = false; + if (resp.data.reason == 'invaliduser') { + $('#invalidadminModal').modal({}); + } else { + $('#cannotconvertModal').modal({}); + } + }); + }; + + $scope.changeUsername = function() { + UserService.load(); + + $scope.updatingUser = true; + + ApiService.changeUserDetails($scope.cuser).then(function() { + $scope.updatingUser = false; + + // Reset the form. + delete $scope.cuser['username']; + + $scope.changeUsernameForm.$setPristine(); + }, function(result) { + $scope.updatingUser = false; + UIService.showFormError('#changeUsernameForm', result); + }); + }; + + $scope.changeEmail = function() { + UIService.hidePopover('#changeEmailForm'); + + $scope.updatingUser = true; + $scope.changeEmailSent = false; + + ApiService.changeUserDetails($scope.cuser).then(function() { + $scope.updatingUser = false; + $scope.changeEmailSent = true; + $scope.sentEmail = $scope.cuser.email; + + // Reset the form. + delete $scope.cuser['email']; + + $scope.changeEmailForm.$setPristine(); + }, function(result) { + $scope.updatingUser = false; + UIService.showFormError('#changeEmailForm', result); + }); + }; + + $scope.changePassword = function() { + UIService.hidePopover('#changePasswordForm'); + + $scope.updatingUser = true; + $scope.changePasswordSuccess = false; + + ApiService.changeUserDetails($scope.cuser).then(function(resp) { + + $scope.updatingUser = false; + $scope.changePasswordSuccess = true; + + // Reset the form + delete $scope.cuser['password'] + delete $scope.cuser['repeatPassword'] + + $scope.changePasswordForm.$setPristine(); + + // Reload the user. + UserService.load(); + }, function(result) { + $scope.updatingUser = false; + UIService.showFormError('#changePasswordForm', result); + }); + }; + + $scope.detachExternalLogin = function(kind) { + var params = { + 'servicename': kind + }; + + ApiService.detachExternalLogin(null, params).then(function() { + $scope.hasGithubLogin = false; + $scope.hasGoogleLogin = false; + UserService.load(); + }, ApiService.errorDisplay('Count not detach service')); + }; + } +})(); \ No newline at end of file diff --git a/static/js/services/angular-helper.js b/static/js/services/angular-helper.js new file mode 100644 index 000000000..2c04f245b --- /dev/null +++ b/static/js/services/angular-helper.js @@ -0,0 +1,42 @@ +/** + * Helper code for working with angular. + */ +angular.module('quay').factory('AngularHelper', [function($routeProvider) { + var helper = {}; + + helper.buildConditionalLinker = function($animate, name, evaluator) { + // Based off of a solution found here: http://stackoverflow.com/questions/20325480/angularjs-whats-the-best-practice-to-add-ngif-to-a-directive-programmatically + return function ($scope, $element, $attr, ctrl, $transclude) { + var block; + var childScope; + var roles; + + $attr.$observe(name, function (value) { + if (evaluator($scope.$eval(value))) { + if (!childScope) { + childScope = $scope.$new(); + $transclude(childScope, function (clone) { + block = { + startNode: clone[0], + endNode: clone[clone.length++] = document.createComment(' end ' + name + ': ' + $attr[name] + ' ') + }; + $animate.enter(clone, $element.parent(), $element); + }); + } + } else { + if (childScope) { + childScope.$destroy(); + childScope = null; + } + + if (block) { + $animate.leave(getBlockElements(block)); + block = null; + } + } + }); + } + }; + + return helper; +}]); diff --git a/static/js/services/angular-poll-channel.js b/static/js/services/angular-poll-channel.js new file mode 100644 index 000000000..b38f2a17e --- /dev/null +++ b/static/js/services/angular-poll-channel.js @@ -0,0 +1,72 @@ +/** + * Specialized class for conducting an HTTP poll, while properly preventing multiple calls. + */ +angular.module('quay').factory('AngularPollChannel', ['ApiService', '$timeout', function(ApiService, $timeout) { + var _PollChannel = function(scope, requester, opt_sleeptime) { + this.scope_ = scope; + this.requester_ = requester; + this.sleeptime_ = opt_sleeptime || (60 * 1000 /* 60s */); + this.timer_ = null; + + this.working = false; + this.polling = false; + + var that = this; + scope.$on('$destroy', function() { + that.stop(); + }); + }; + + _PollChannel.prototype.stop = function() { + if (this.timer_) { + $timeout.cancel(this.timer_); + this.timer_ = null; + this.polling_ = false; + } + + this.working = false; + }; + + _PollChannel.prototype.start = function() { + // Make sure we invoke call outside the normal digest cycle, since + // we'll call $scope.$apply ourselves. + var that = this; + setTimeout(function() { that.call_(); }, 0); + }; + + _PollChannel.prototype.call_ = function() { + if (this.working) { return; } + + var that = this; + this.working = true; + this.scope_.$apply(function() { + that.requester_(function(status) { + if (status) { + that.working = false; + that.setupTimer_(); + } else { + that.stop(); + } + }); + }); + }; + + _PollChannel.prototype.setupTimer_ = function() { + if (this.timer_) { return; } + + var that = this; + this.polling = true; + this.timer_ = $timeout(function() { + that.timer_ = null; + that.call_(); + }, this.sleeptime_) + }; + + var service = { + 'create': function(scope, requester, opt_sleeptime) { + return new _PollChannel(scope, requester, opt_sleeptime); + } + }; + + return service; +}]); \ No newline at end of file diff --git a/static/js/services/angular-view-array.js b/static/js/services/angular-view-array.js new file mode 100644 index 000000000..698ba2f61 --- /dev/null +++ b/static/js/services/angular-view-array.js @@ -0,0 +1,87 @@ + /** + * Specialized wrapper around array which provides a toggle() method for viewing the contents of the + * array in a manner that is asynchronously filled in over a short time period. This prevents long + * pauses in the UI for ngRepeat's when the array is significant in size. + */ +angular.module('quay').factory('AngularViewArray', ['$interval', function($interval) { + var ADDTIONAL_COUNT = 20; + + function _ViewArray() { + this.isVisible = false; + this.visibleEntries = null; + this.hasEntries = false; + this.entries = []; + + this.timerRef_ = null; + this.currentIndex_ = 0; + } + + _ViewArray.prototype.length = function() { + return this.entries.length; + }; + + _ViewArray.prototype.get = function(index) { + return this.entries[index]; + }; + + _ViewArray.prototype.push = function(elem) { + this.entries.push(elem); + this.hasEntries = true; + + if (this.isVisible) { + this.setVisible(true); + } + }; + + _ViewArray.prototype.toggle = function() { + this.setVisible(!this.isVisible); + }; + + _ViewArray.prototype.setVisible = function(newState) { + this.isVisible = newState; + + this.visibleEntries = []; + this.currentIndex_ = 0; + + if (newState) { + this.showAdditionalEntries_(); + this.startTimer_(); + } else { + this.stopTimer_(); + } + }; + + _ViewArray.prototype.showAdditionalEntries_ = function() { + var i = 0; + for (i = this.currentIndex_; i < (this.currentIndex_ + ADDTIONAL_COUNT) && i < this.entries.length; ++i) { + this.visibleEntries.push(this.entries[i]); + } + + this.currentIndex_ = i; + if (this.currentIndex_ >= this.entries.length) { + this.stopTimer_(); + } + }; + + _ViewArray.prototype.startTimer_ = function() { + var that = this; + this.timerRef_ = $interval(function() { + that.showAdditionalEntries_(); + }, 10); + }; + + _ViewArray.prototype.stopTimer_ = function() { + if (this.timerRef_) { + $interval.cancel(this.timerRef_); + this.timerRef_ = null; + } + }; + + var service = { + 'create': function() { + return new _ViewArray(); + } + }; + + return service; +}]); \ No newline at end of file diff --git a/static/js/services/api-service.js b/static/js/services/api-service.js new file mode 100644 index 000000000..08af7d6ff --- /dev/null +++ b/static/js/services/api-service.js @@ -0,0 +1,330 @@ +/** + * Service which exposes the server-defined API as a nice set of helper methods and automatic + * callbacks. Any method defined on the server is exposed here as an equivalent method. Also + * defines some helper functions for working with API responses. + */ +angular.module('quay').factory('ApiService', ['Restangular', '$q', function(Restangular, $q) { + var apiService = {}; + + var getResource = function(path, opt_background) { + var resource = {}; + resource.url = path; + resource.withOptions = function(options) { + this.options = options; + return this; + }; + + resource.get = function(processor, opt_errorHandler) { + var options = this.options; + var performer = Restangular.one(this.url); + + var result = { + 'loading': true, + 'value': null, + 'hasError': false + }; + + if (opt_background) { + performer.withHttpConfig({ + 'ignoreLoadingBar': true + }); + } + + performer.get(options).then(function(resp) { + result.value = processor(resp); + result.loading = false; + }, function(resp) { + result.hasError = true; + result.loading = false; + if (opt_errorHandler) { + opt_errorHandler(resp); + } + }); + + return result; + }; + + return resource; + }; + + var buildUrl = function(path, parameters, opt_forcessl) { + // We already have /api/v1/ on the URLs, so remove them from the paths. + path = path.substr('/api/v1/'.length, path.length); + + // Build the path, adjusted with the inline parameters. + var used = {}; + var url = ''; + for (var i = 0; i < path.length; ++i) { + var c = path[i]; + if (c == '{') { + var end = path.indexOf('}', i); + var varName = path.substr(i + 1, end - i - 1); + + if (!parameters[varName]) { + throw new Error('Missing parameter: ' + varName); + } + + used[varName] = true; + url += parameters[varName]; + i = end; + continue; + } + + url += c; + } + + // Append any query parameters. + var isFirst = true; + for (var paramName in parameters) { + if (!parameters.hasOwnProperty(paramName)) { continue; } + if (used[paramName]) { continue; } + + var value = parameters[paramName]; + if (value) { + url += isFirst ? '?' : '&'; + url += paramName + '=' + encodeURIComponent(value) + isFirst = false; + } + } + + // If we are forcing SSL, return an absolutel URL with an SSL prefix. + if (opt_forcessl) { + path = 'https://' + window.location.host + '/api/v1/' + path; + } + + return url; + }; + + var getGenericOperationName = function(userOperationName) { + return userOperationName.replace('User', ''); + }; + + var getMatchingUserOperationName = function(orgOperationName, method, userRelatedResource) { + if (userRelatedResource) { + var operations = userRelatedResource['operations']; + for (var i = 0; i < operations.length; ++i) { + var operation = operations[i]; + if (operation['method'].toLowerCase() == method) { + return operation['nickname']; + } + } + } + + throw new Error('Could not find user operation matching org operation: ' + orgOperationName); + }; + + var buildMethodsForEndpointResource = function(endpointResource, resourceMap) { + var name = endpointResource['name']; + var operations = endpointResource['operations']; + for (var i = 0; i < operations.length; ++i) { + var operation = operations[i]; + buildMethodsForOperation(operation, endpointResource, resourceMap); + } + }; + + var freshLoginInProgress = []; + var reject = function(msg) { + for (var i = 0; i < freshLoginInProgress.length; ++i) { + freshLoginInProgress[i].deferred.reject({'data': {'message': msg}}); + } + freshLoginInProgress = []; + }; + + var retry = function() { + for (var i = 0; i < freshLoginInProgress.length; ++i) { + freshLoginInProgress[i].retry(); + } + freshLoginInProgress = []; + }; + + var freshLoginFailCheck = function(opName, opArgs) { + return function(resp) { + var deferred = $q.defer(); + + // If the error is a fresh login required, show the dialog. + if (resp.status == 401 && resp.data['error_type'] == 'fresh_login_required') { + var retryOperation = function() { + apiService[opName].apply(apiService, opArgs).then(function(resp) { + deferred.resolve(resp); + }, function(resp) { + deferred.reject(resp); + }); + }; + + var verifyNow = function() { + var info = { + 'password': $('#freshPassword').val() + }; + + $('#freshPassword').val(''); + + // Conduct the sign in of the user. + apiService.verifyUser(info).then(function() { + // On success, retry the operations. if it succeeds, then resolve the + // deferred promise with the result. Otherwise, reject the same. + retry(); + }, function(resp) { + // Reject with the sign in error. + reject('Invalid verification credentials'); + }); + }; + + // Add the retry call to the in progress list. If there is more than a single + // in progress call, we skip showing the dialog (since it has already been + // shown). + freshLoginInProgress.push({ + 'deferred': deferred, + 'retry': retryOperation + }) + + if (freshLoginInProgress.length > 1) { + return deferred.promise; + } + + var box = bootbox.dialog({ + "message": 'It has been more than a few minutes since you last logged in, ' + + 'so please verify your password to perform this sensitive operation:' + + '
' + + '' + + '
', + "title": 'Please Verify', + "buttons": { + "verify": { + "label": "Verify", + "className": "btn-success", + "callback": verifyNow + }, + "close": { + "label": "Cancel", + "className": "btn-default", + "callback": function() { + reject('Verification canceled') + } + } + } + }); + + box.bind('shown.bs.modal', function(){ + box.find("input").focus(); + box.find("form").submit(function() { + if (!$('#freshPassword').val()) { return; } + + box.modal('hide'); + verifyNow(); + }); + }); + + // Return a new promise. We'll accept or reject it based on the result + // of the login. + return deferred.promise; + } + + // Otherwise, we just 'raise' the error via the reject method on the promise. + return $q.reject(resp); + }; + }; + + var buildMethodsForOperation = function(operation, resource, resourceMap) { + var method = operation['method'].toLowerCase(); + var operationName = operation['nickname']; + var path = resource['path']; + + // Add the operation itself. + apiService[operationName] = function(opt_options, opt_parameters, opt_background, opt_forcessl) { + var one = Restangular.one(buildUrl(path, opt_parameters, opt_forcessl)); + if (opt_background) { + one.withHttpConfig({ + 'ignoreLoadingBar': true + }); + } + + var opObj = one['custom' + method.toUpperCase()](opt_options); + + // If the operation requires_fresh_login, then add a specialized error handler that + // will defer the operation's result if sudo is requested. + if (operation['requires_fresh_login']) { + opObj = opObj.catch(freshLoginFailCheck(operationName, arguments)); + } + return opObj; + }; + + // If the method for the operation is a GET, add an operationAsResource method. + if (method == 'get') { + apiService[operationName + 'AsResource'] = function(opt_parameters, opt_background) { + return getResource(buildUrl(path, opt_parameters), opt_background); + }; + } + + // If the resource has a user-related resource, then make a generic operation for this operation + // that can call both the user and the organization versions of the operation, depending on the + // parameters given. + if (resource['quayUserRelated']) { + var userOperationName = getMatchingUserOperationName(operationName, method, resourceMap[resource['quayUserRelated']]); + var genericOperationName = getGenericOperationName(userOperationName); + apiService[genericOperationName] = function(orgname, opt_options, opt_parameters, opt_background) { + if (orgname) { + if (orgname.name) { + orgname = orgname.name; + } + + var params = jQuery.extend({'orgname' : orgname}, opt_parameters || {}, opt_background); + return apiService[operationName](opt_options, params); + } else { + return apiService[userOperationName](opt_options, opt_parameters, opt_background); + } + }; + } + }; + + if (!window.__endpoints) { + return apiService; + } + + var resourceMap = {}; + + // Build the map of resource names to their objects. + for (var i = 0; i < window.__endpoints.length; ++i) { + var endpointResource = window.__endpoints[i]; + resourceMap[endpointResource['name']] = endpointResource; + } + + // Construct the methods for each API endpoint. + for (var i = 0; i < window.__endpoints.length; ++i) { + var endpointResource = window.__endpoints[i]; + buildMethodsForEndpointResource(endpointResource, resourceMap); + } + + apiService.getErrorMessage = function(resp, defaultMessage) { + var message = defaultMessage; + if (resp['data']) { + message = resp['data']['error_message'] || resp['data']['message'] || resp['data']['error_description'] || message; + } + + return message; + }; + + apiService.errorDisplay = function(defaultMessage, opt_handler) { + return function(resp) { + var message = apiService.getErrorMessage(resp, defaultMessage); + if (opt_handler) { + var handlerMessage = opt_handler(resp); + if (handlerMessage) { + message = handlerMessage; + } + } + + bootbox.dialog({ + "message": message, + "title": defaultMessage, + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }; + }; + + return apiService; +}]); diff --git a/static/js/services/avatar-service.js b/static/js/services/avatar-service.js new file mode 100644 index 000000000..500475000 --- /dev/null +++ b/static/js/services/avatar-service.js @@ -0,0 +1,48 @@ +/** + * Service which provides helper methods for retrieving the avatars displayed in the app. + */ +angular.module('quay').factory('AvatarService', ['Config', '$sanitize', 'md5', + function(Config, $sanitize, md5) { + var avatarService = {}; + var cache = {}; + + avatarService.getAvatar = function(hash, opt_size) { + var size = opt_size || 16; + switch (Config['AVATAR_KIND']) { + case 'local': + return '/avatar/' + hash + '?size=' + size; + break; + + case 'gravatar': + return '//www.gravatar.com/avatar/' + hash + '?d=identicon&size=' + size; + break; + } + }; + + avatarService.computeHash = function(opt_email, opt_name) { + var email = opt_email || ''; + var name = opt_name || ''; + + var cacheKey = email + ':' + name; + if (!cacheKey) { return '-'; } + + if (cache[cacheKey]) { + return cache[cacheKey]; + } + + var hash = md5.createHash(email.toString().toLowerCase()); + switch (Config['AVATAR_KIND']) { + case 'local': + if (name) { + hash = name[0] + hash; + } else if (email) { + hash = email[0] + hash; + } + break; + } + + return cache[cacheKey] = hash; + }; + + return avatarService; +}]); \ No newline at end of file diff --git a/static/js/services/container-service.js b/static/js/services/container-service.js new file mode 100644 index 000000000..3d9036d29 --- /dev/null +++ b/static/js/services/container-service.js @@ -0,0 +1,36 @@ +/** + * Helper service for working with the registry's container. Only works in enterprise. + */ +angular.module('quay').factory('ContainerService', ['ApiService', '$timeout', + +function(ApiService, $timeout) { + var containerService = {}; + containerService.restartContainer = function(callback) { + ApiService.scShutdownContainer(null, null).then(function(resp) { + $timeout(callback, 2000); + }, ApiService.errorDisplay('Cannot restart container. Please report this to support.')) + }; + + containerService.scheduleStatusCheck = function(callback) { + $timeout(function() { + containerService.checkStatus(callback); + }, 2000); + }; + + containerService.checkStatus = function(callback, force_ssl) { + var errorHandler = function(resp) { + if (resp.status == 404 || resp.status == 502) { + // Container has not yet come back up, so we schedule another check. + containerService.scheduleStatusCheck(callback); + return; + } + + return ApiService.errorDisplay('Cannot load status. Please report this to support')(resp); + }; + + ApiService.scRegistryStatus(null, null) + .then(callback, errorHandler, /* background */true, /* force ssl*/force_ssl); + }; + + return containerService; +}]); diff --git a/static/js/services/cookie-service.js b/static/js/services/cookie-service.js new file mode 100644 index 000000000..186f933a9 --- /dev/null +++ b/static/js/services/cookie-service.js @@ -0,0 +1,23 @@ +/** + * Helper service for working with cookies. + */ +angular.module('quay').factory('CookieService', ['$cookies', '$cookieStore', function($cookies, $cookieStore) { + var cookieService = {}; + cookieService.putPermanent = function(name, value) { + document.cookie = escape(name) + "=" + escape(value) + "; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/"; + }; + + cookieService.putSession = function(name, value) { + $cookies[name] = value; + }; + + cookieService.clear = function(name) { + $cookies[name] = ''; + }; + + cookieService.get = function(name) { + return $cookies[name]; + }; + + return cookieService; +}]); diff --git a/static/js/services/create-service.js b/static/js/services/create-service.js new file mode 100644 index 000000000..25c308e22 --- /dev/null +++ b/static/js/services/create-service.js @@ -0,0 +1,28 @@ +/** + * Service which exposes various methods for creating entities on the backend. + */ +angular.module('quay').factory('CreateService', ['ApiService', function(ApiService) { + var createService = {}; + + createService.createRobotAccount = function(ApiService, is_org, orgname, name, callback) { + ApiService.createRobot(is_org ? orgname : null, null, {'robot_shortname': name}) + .then(callback, ApiService.errorDisplay('Cannot create robot account')); + }; + + createService.createOrganizationTeam = function(ApiService, orgname, teamname, callback) { + var data = { + 'name': teamname, + 'role': 'member' + }; + + var params = { + 'orgname': orgname, + 'teamname': teamname + }; + + ApiService.updateOrganizationTeam(data, params) + .then(callback, ApiService.errorDisplay('Cannot create team')); + }; + + return createService; +}]); diff --git a/static/js/services/datafile-service.js b/static/js/services/datafile-service.js new file mode 100644 index 000000000..2471ac32f --- /dev/null +++ b/static/js/services/datafile-service.js @@ -0,0 +1,170 @@ +/** + * Service which provides helper methods for downloading a data file from a URL, and extracting + * its contents as .tar, .tar.gz, or .zip file. Note that this service depends on external + * library code in the lib/ directory: + * - jszip.min.js + * - Blob.js + * - zlib.js + */ +angular.module('quay').factory('DataFileService', [function() { + var dataFileService = {}; + + dataFileService.getName_ = function(filePath) { + var parts = filePath.split('/'); + return parts[parts.length - 1]; + }; + + dataFileService.tryAsZip_ = function(buf, success, failure) { + var zip = null; + var zipFiles = null; + try { + var zip = new JSZip(buf); + zipFiles = zip.files; + } catch (e) { + failure(); + return; + } + + var files = []; + for (var filePath in zipFiles) { + if (zipFiles.hasOwnProperty(filePath)) { + files.push({ + 'name': dataFileService.getName_(filePath), + 'path': filePath, + 'canRead': true, + 'toBlob': (function(fp) { + return function() { + return new Blob([zip.file(fp).asArrayBuffer()]); + }; + }(filePath)) + }); + } + } + + success(files); + }; + + dataFileService.tryAsTarGz_ = function(buf, success, failure) { + var gunzip = new Zlib.Gunzip(buf); + var plain = null; + + try { + plain = gunzip.decompress(); + } catch (e) { + failure(); + return; + } + + dataFileService.tryAsTar_(plain, success, failure); + }; + + dataFileService.tryAsTar_ = function(buf, success, failure) { + var collapsePath = function(originalPath) { + // Tar files can contain entries of the form './', so we need to collapse + // those paths down. + var parts = originalPath.split('/'); + for (var i = parts.length - 1; i >= 0; i--) { + var part = parts[i]; + if (part == '.') { + parts.splice(i, 1); + } + } + return parts.join('/'); + }; + + var handler = new Untar(buf); + handler.process(function(status, read, files, err) { + switch (status) { + case 'error': + failure(err); + break; + + case 'done': + var processed = []; + for (var i = 0; i < files.length; ++i) { + var currentFile = files[i]; + var path = collapsePath(currentFile.meta.filename); + + if (path == '' || path == 'pax_global_header') { continue; } + + processed.push({ + 'name': dataFileService.getName_(path), + 'path': path, + 'canRead': true, + 'toBlob': (function(currentFile) { + return function() { + return new Blob([currentFile.buffer], {type: 'application/octet-binary'}); + }; + }(currentFile)) + }); + } + success(processed); + break; + } + }); + }; + + dataFileService.blobToString = function(blob, callback) { + var reader = new FileReader(); + reader.onload = function(event){ + callback(reader.result); + }; + reader.readAsText(blob); + }; + + dataFileService.arrayToString = function(buf, callback) { + var bb = new Blob([buf], {type: 'application/octet-binary'}); + var f = new FileReader(); + f.onload = function(e) { + callback(e.target.result); + }; + f.onerror = function(e) { + callback(null); + }; + f.onabort = function(e) { + callback(null); + }; + f.readAsText(bb); + }; + + dataFileService.readDataArrayAsPossibleArchive = function(buf, success, failure) { + dataFileService.tryAsZip_(buf, success, function() { + dataFileService.tryAsTarGz_(buf, success, failure); + }); + }; + + dataFileService.downloadDataFileAsArrayBuffer = function($scope, url, progress, error, loaded) { + var request = new XMLHttpRequest(); + request.open('GET', url, true); + request.responseType = 'arraybuffer'; + + request.onprogress = function(e) { + $scope.$apply(function() { + var percentLoaded; + if (e.lengthComputable) { + progress(e.loaded / e.total); + } + }); + }; + + request.onerror = function() { + $scope.$apply(function() { + error(); + }); + }; + + request.onload = function() { + if (this.status == 200) { + $scope.$apply(function() { + var uint8array = new Uint8Array(request.response); + loaded(uint8array); + }); + return; + } + }; + + request.send(); + }; + + return dataFileService; +}]); diff --git a/static/js/services/external-notification-data.js b/static/js/services/external-notification-data.js new file mode 100644 index 000000000..10a73dea9 --- /dev/null +++ b/static/js/services/external-notification-data.js @@ -0,0 +1,166 @@ +/** + * Service which defines the various kinds of external notification and provides methods for + * easily looking up information about those kinds. + */ +angular.module('quay').factory('ExternalNotificationData', ['Config', 'Features', + +function(Config, Features) { + var externalNotificationData = {}; + + var events = [ + { + 'id': 'repo_push', + 'title': 'Push to Repository', + 'icon': 'fa-upload' + } + ]; + + if (Features.BUILD_SUPPORT) { + var buildEvents = [ + { + 'id': 'build_queued', + 'title': 'Dockerfile Build Queued', + 'icon': 'fa-tasks' + }, + { + 'id': 'build_start', + 'title': 'Dockerfile Build Started', + 'icon': 'fa-circle-o-notch' + }, + { + 'id': 'build_success', + 'title': 'Dockerfile Build Successfully Completed', + 'icon': 'fa-check-circle-o' + }, + { + 'id': 'build_failure', + 'title': 'Dockerfile Build Failed', + 'icon': 'fa-times-circle-o' + }]; + + for (var i = 0; i < buildEvents.length; ++i) { + events.push(buildEvents[i]); + } + } + + var methods = [ + { + 'id': 'quay_notification', + 'title': Config.REGISTRY_TITLE + ' Notification', + 'icon': 'quay-icon', + 'fields': [ + { + 'name': 'target', + 'type': 'entity', + 'title': 'Recipient' + } + ] + }, + { + 'id': 'email', + 'title': 'E-mail', + 'icon': 'fa-envelope', + 'fields': [ + { + 'name': 'email', + 'type': 'email', + 'title': 'E-mail address' + } + ], + 'enabled': Features.MAILING + }, + { + 'id': 'webhook', + 'title': 'Webhook POST', + 'icon': 'fa-link', + 'fields': [ + { + 'name': 'url', + 'type': 'url', + 'title': 'Webhook URL' + } + ] + }, + { + 'id': 'flowdock', + 'title': 'Flowdock Team Notification', + 'icon': 'flowdock-icon', + 'fields': [ + { + 'name': 'flow_api_token', + 'type': 'string', + 'title': 'Flow API Token', + 'help_url': 'https://www.flowdock.com/account/tokens' + } + ] + }, + { + 'id': 'hipchat', + 'title': 'HipChat Room Notification', + 'icon': 'hipchat-icon', + 'fields': [ + { + 'name': 'room_id', + 'type': 'string', + 'title': 'Room ID #' + }, + { + 'name': 'notification_token', + 'type': 'string', + 'title': 'Room Notification Token', + 'help_url': 'https://hipchat.com/rooms/tokens/{room_id}' + } + ] + }, + { + 'id': 'slack', + 'title': 'Slack Room Notification', + 'icon': 'slack-icon', + 'fields': [ + { + 'name': 'url', + 'type': 'regex', + 'title': 'Webhook URL', + 'regex': '^https://hooks\\.slack\\.com/services/[A-Z0-9]+/[A-Z0-9]+/[a-zA-Z0-9]+$', + 'help_url': 'https://slack.com/services/new/incoming-webhook', + 'placeholder': 'https://hooks.slack.com/service/{some}/{token}/{here}' + } + ] + } + ]; + + var methodMap = {}; + var eventMap = {}; + + for (var i = 0; i < methods.length; ++i) { + methodMap[methods[i].id] = methods[i]; + } + + for (var i = 0; i < events.length; ++i) { + eventMap[events[i].id] = events[i]; + } + + externalNotificationData.getSupportedEvents = function() { + return events; + }; + + externalNotificationData.getSupportedMethods = function() { + var filtered = []; + for (var i = 0; i < methods.length; ++i) { + if (methods[i].enabled !== false) { + filtered.push(methods[i]); + } + } + return filtered; + }; + + externalNotificationData.getEventInfo = function(event) { + return eventMap[event]; + }; + + externalNotificationData.getMethodInfo = function(method) { + return methodMap[method]; + }; + + return externalNotificationData; +}]); \ No newline at end of file diff --git a/static/js/services/features-config.js b/static/js/services/features-config.js new file mode 100644 index 000000000..e65f2fb9f --- /dev/null +++ b/static/js/services/features-config.js @@ -0,0 +1,71 @@ +/** + * Feature flags. + */ +angular.module('quay').factory('Features', [function() { + if (!window.__features) { + return {}; + } + + var features = window.__features; + features.getFeature = function(name, opt_defaultValue) { + var value = features[name]; + if (value == null) { + return opt_defaultValue; + } + return value; + }; + + features.hasFeature = function(name) { + return !!features.getFeature(name); + }; + + features.matchesFeatures = function(list) { + for (var i = 0; i < list.length; ++i) { + var value = features.getFeature(list[i]); + if (!value) { + return false; + } + } + return true; + }; + + return features; +}]); + +/** + * Application configuration. + */ +angular.module('quay').factory('Config', [function() { + if (!window.__config) { + return {}; + } + + var config = window.__config; + config.getDomain = function() { + return config['SERVER_HOSTNAME']; + }; + + config.getHost = function(opt_auth) { + var auth = opt_auth; + if (auth) { + auth = auth + '@'; + } + + return config['PREFERRED_URL_SCHEME'] + '://' + auth + config['SERVER_HOSTNAME']; + }; + + config.getUrl = function(opt_path) { + var path = opt_path || ''; + return config['PREFERRED_URL_SCHEME'] + '://' + config['SERVER_HOSTNAME'] + path; + }; + + config.getValue = function(name, opt_defaultValue) { + var value = config[name]; + if (value == null) { + return opt_defaultValue; + } + return value; + }; + + return config; +}]); \ No newline at end of file diff --git a/static/js/services/image-metadata-service.js b/static/js/services/image-metadata-service.js new file mode 100644 index 000000000..819d12a2b --- /dev/null +++ b/static/js/services/image-metadata-service.js @@ -0,0 +1,28 @@ +/** + * Helper service for returning information extracted from repository image metadata. + */ +angular.module('quay').factory('ImageMetadataService', ['UtilService', function(UtilService) { + var metadataService = {}; + metadataService.getFormattedCommand = function(image) { + if (!image || !image.command || !image.command.length) { + return ''; + } + + var getCommandStr = function(command) { + // Handle /bin/sh commands specially. + if (command.length > 2 && command[0] == '/bin/sh' && command[1] == '-c') { + return command[2]; + } + + return command.join(' '); + }; + + return getCommandStr(image.command); + }; + + metadataService.getEscapedFormattedCommand = function(image) { + return UtilService.textToSafeHtml(metadataService.getFormattedCommand(image)); + }; + + return metadataService; +}]); \ No newline at end of file diff --git a/static/js/services/key-service.js b/static/js/services/key-service.js new file mode 100644 index 000000000..1ab153635 --- /dev/null +++ b/static/js/services/key-service.js @@ -0,0 +1,63 @@ +/** + * Service which provides access to the various keys defined in configuration, and working with + * external services that rely on those keys. + */ +angular.module('quay').factory('KeyService', ['$location', 'Config', function($location, Config) { + var keyService = {} + var oauth = window.__oauth; + + keyService['stripePublishableKey'] = Config['STRIPE_PUBLISHABLE_KEY']; + + keyService['githubTriggerClientId'] = oauth['GITHUB_TRIGGER_CONFIG']['CLIENT_ID']; + keyService['githubLoginClientId'] = oauth['GITHUB_LOGIN_CONFIG']['CLIENT_ID']; + keyService['googleLoginClientId'] = oauth['GOOGLE_LOGIN_CONFIG']['CLIENT_ID']; + + keyService['githubRedirectUri'] = Config.getUrl('/oauth2/github/callback'); + keyService['googleRedirectUri'] = Config.getUrl('/oauth2/google/callback'); + + keyService['githubLoginUrl'] = oauth['GITHUB_LOGIN_CONFIG']['AUTHORIZE_ENDPOINT']; + keyService['googleLoginUrl'] = oauth['GOOGLE_LOGIN_CONFIG']['AUTHORIZE_ENDPOINT']; + + keyService['githubEndpoint'] = oauth['GITHUB_LOGIN_CONFIG']['GITHUB_ENDPOINT']; + + keyService['githubTriggerEndpoint'] = oauth['GITHUB_TRIGGER_CONFIG']['GITHUB_ENDPOINT']; + keyService['githubTriggerAuthorizeUrl'] = oauth['GITHUB_TRIGGER_CONFIG']['AUTHORIZE_ENDPOINT']; + + keyService['githubLoginScope'] = 'user:email'; + keyService['googleLoginScope'] = 'openid email'; + + keyService.isEnterprise = function(service) { + switch (service) { + case 'github': + return keyService['githubLoginUrl'].indexOf('https://github.com/') < 0; + + case 'github-trigger': + return keyService['githubTriggerAuthorizeUrl'].indexOf('https://github.com/') < 0; + } + + return false; + }; + + keyService.getExternalLoginUrl = function(service, action) { + var state_clause = ''; + if (Config.MIXPANEL_KEY && window.mixpanel) { + if (mixpanel.get_distinct_id !== undefined) { + state_clause = "&state=" + encodeURIComponent(mixpanel.get_distinct_id()); + } + } + + var client_id = keyService[service + 'LoginClientId']; + var scope = keyService[service + 'LoginScope']; + var redirect_uri = keyService[service + 'RedirectUri']; + if (action == 'attach') { + redirect_uri += '/attach'; + } + + var url = keyService[service + 'LoginUrl'] + 'client_id=' + client_id + '&scope=' + scope + + '&redirect_uri=' + redirect_uri + state_clause; + + return url; + }; + + return keyService; +}]); \ No newline at end of file diff --git a/static/js/services/notification-service.js b/static/js/services/notification-service.js new file mode 100644 index 000000000..235083baa --- /dev/null +++ b/static/js/services/notification-service.js @@ -0,0 +1,228 @@ +/** + * Service which defines the supported kinds of application notifications (those items that appear + * in the sidebar) and provides helper methods for working with them. + */ +angular.module('quay').factory('NotificationService', + ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', '$location', + +function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config, $location) { + var notificationService = { + 'user': null, + 'notifications': [], + 'notificationClasses': [], + 'notificationSummaries': [], + 'additionalNotifications': false + }; + + var pollTimerHandle = null; + + var notificationKinds = { + 'test_notification': { + 'level': 'primary', + 'message': 'This notification is a long message for testing: {obj}', + 'page': '/about/', + 'dismissable': true + }, + 'org_team_invite': { + 'level': 'primary', + 'message': '{inviter} is inviting you to join team {team} under organization {org}', + 'actions': [ + { + 'title': 'Join team', + 'kind': 'primary', + 'handler': function(notification) { + window.location = '/confirminvite?code=' + notification.metadata['code']; + } + }, + { + 'title': 'Decline', + 'kind': 'default', + 'handler': function(notification) { + ApiService.declineOrganizationTeamInvite(null, {'code': notification.metadata['code']}).then(function() { + notificationService.update(); + }); + } + } + ] + }, + 'password_required': { + 'level': 'error', + 'message': 'In order to begin pushing and pulling repositories, a password must be set for your account', + 'page': '/user?tab=password' + }, + 'over_private_usage': { + 'level': 'error', + 'message': 'Namespace {namespace} is over its allowed private repository count. ' + + '

Please upgrade your plan to avoid disruptions in service.', + 'page': function(metadata) { + var organization = UserService.getOrganization(metadata['namespace']); + if (organization) { + return '/organization/' + metadata['namespace'] + '/admin'; + } else { + return '/user'; + } + } + }, + 'expiring_license': { + 'level': 'error', + 'message': 'Your license will expire at: {expires_at} ' + + '

Please contact support to purchase a new license.', + 'page': '/contact/' + }, + 'maintenance': { + 'level': 'warning', + 'message': 'We will be down for schedule maintenance from {from_date} to {to_date} ' + + 'for {reason}. We are sorry about any inconvenience.', + 'page': 'http://status.quay.io/' + }, + 'repo_push': { + 'level': 'info', + 'message': function(metadata) { + if (metadata.updated_tags && Object.getOwnPropertyNames(metadata.updated_tags).length) { + return 'Repository {repository} has been pushed with the following tags updated: {updated_tags}'; + } else { + return 'Repository {repository} fhas been pushed'; + } + }, + 'page': function(metadata) { + return '/repository/' + metadata.repository; + }, + 'dismissable': true + }, + 'build_queued': { + 'level': 'info', + 'message': 'A build has been queued for repository {repository}', + 'page': function(metadata) { + return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; + }, + 'dismissable': true + }, + 'build_start': { + 'level': 'info', + 'message': 'A build has been started for repository {repository}', + 'page': function(metadata) { + return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; + }, + 'dismissable': true + }, + 'build_success': { + 'level': 'info', + 'message': 'A build has succeeded for repository {repository}', + 'page': function(metadata) { + return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; + }, + 'dismissable': true + }, + 'build_failure': { + 'level': 'error', + 'message': 'A build has failed for repository {repository}', + 'page': function(metadata) { + return '/repository/' + metadata.repository + '/build?current=' + metadata.build_id; + }, + 'dismissable': true + } + }; + + notificationService.dismissNotification = function(notification) { + notification.dismissed = true; + var params = { + 'uuid': notification.id + }; + + ApiService.updateUserNotification(notification, params, function() { + notificationService.update(); + }, ApiService.errorDisplay('Could not update notification')); + + var index = $.inArray(notification, notificationService.notifications); + if (index >= 0) { + notificationService.notifications.splice(index, 1); + } + }; + + notificationService.getActions = function(notification) { + var kindInfo = notificationKinds[notification['kind']]; + if (!kindInfo) { + return []; + } + + return kindInfo['actions'] || []; + }; + + notificationService.canDismiss = function(notification) { + var kindInfo = notificationKinds[notification['kind']]; + if (!kindInfo) { + return false; + } + return !!kindInfo['dismissable']; + }; + + notificationService.getPage = function(notification) { + var kindInfo = notificationKinds[notification['kind']]; + if (!kindInfo) { + return null; + } + + var page = kindInfo['page']; + if (page != null && typeof page != 'string') { + page = page(notification['metadata']); + } + return page || ''; + }; + + notificationService.getMessage = function(notification) { + var kindInfo = notificationKinds[notification['kind']]; + if (!kindInfo) { + return '(Unknown notification kind: ' + notification['kind'] + ')'; + } + return StringBuilderService.buildString(kindInfo['message'], notification['metadata']); + }; + + notificationService.getClass = function(notification) { + var kindInfo = notificationKinds[notification['kind']]; + if (!kindInfo) { + return 'notification-info'; + } + return 'notification-' + kindInfo['level']; + }; + + notificationService.getClasses = function(notifications) { + var classes = []; + for (var i = 0; i < notifications.length; ++i) { + var notification = notifications[i]; + classes.push(notificationService.getClass(notification)); + } + return classes.join(' '); + }; + + notificationService.update = function() { + var user = UserService.currentUser(); + if (!user || user.anonymous) { + return; + } + + ApiService.listUserNotifications().then(function(resp) { + notificationService.notifications = resp['notifications']; + notificationService.additionalNotifications = resp['additional']; + notificationService.notificationClasses = notificationService.getClasses(notificationService.notifications); + }); + }; + + notificationService.reset = function() { + $interval.cancel(pollTimerHandle); + pollTimerHandle = $interval(notificationService.update, 5 * 60 * 1000 /* five minutes */); + }; + + // Watch for plan changes and update. + PlanService.registerListener(this, function(plan) { + notificationService.reset(); + notificationService.update(); + }); + + // Watch for user changes and update. + $rootScope.$watch(function() { return UserService.currentUser(); }, function(currentUser) { + notificationService.reset(); + notificationService.update(); + }); + + return notificationService; +}]); diff --git a/static/js/services/oauth-service.js b/static/js/services/oauth-service.js new file mode 100644 index 000000000..3b1d595fb --- /dev/null +++ b/static/js/services/oauth-service.js @@ -0,0 +1,8 @@ +/** + * Service which provides the OAuth scopes defined. + */ +angular.module('quay').factory('OAuthService', ['$location', 'Config', function($location, Config) { + var oauthService = {}; + oauthService.SCOPES = window.__auth_scopes; + return oauthService; +}]); \ No newline at end of file diff --git a/static/js/services/ping-service.js b/static/js/services/ping-service.js new file mode 100644 index 000000000..bce2254b4 --- /dev/null +++ b/static/js/services/ping-service.js @@ -0,0 +1,99 @@ +/** + * Service which pings an endpoint URL and estimates the latency to it. + */ +angular.module('quay').factory('PingService', [function() { + var pingService = {}; + var pingCache = {}; + + var invokeCallback = function($scope, pings, callback) { + if (pings[0] == -1) { + setTimeout(function() { + $scope.$apply(function() { + callback(-1, false, -1); + }); + }, 0); + return; + } + + var sum = 0; + for (var i = 0; i < pings.length; ++i) { + sum += pings[i]; + } + + // Report the average ping. + setTimeout(function() { + $scope.$apply(function() { + callback(Math.floor(sum / pings.length), true, pings.length); + }); + }, 0); + }; + + var reportPingResult = function($scope, url, ping, callback) { + // Lookup the cached ping data, if any. + var cached = pingCache[url]; + if (!cached) { + cached = pingCache[url] = { + 'pings': [] + }; + } + + // If an error occurred, report it and done. + if (ping < 0) { + cached['pings'] = [-1]; + invokeCallback($scope, [-1], callback); + return; + } + + // Otherwise, add the current ping and determine the average. + cached['pings'].push(ping); + + // Invoke the callback. + invokeCallback($scope, cached['pings'], callback); + + // Schedule another check if we've done less than three. + if (cached['pings'].length < 3) { + setTimeout(function() { + pingUrlInternal($scope, url, callback); + }, 1000); + } + }; + + var pingUrlInternal = function($scope, url, callback) { + var path = url + '?cb=' + (Math.random() * 100); + var start = new Date(); + var xhr = new XMLHttpRequest(); + xhr.onerror = function() { + reportPingResult($scope, url, -1, callback); + }; + + xhr.onreadystatechange = function () { + if (xhr.readyState === xhr.HEADERS_RECEIVED) { + if (xhr.status != 200) { + reportPingResult($scope, url, -1, callback); + return; + } + + var ping = (new Date() - start); + reportPingResult($scope, url, ping, callback); + } + }; + + xhr.open("GET", path); + xhr.send(null); + }; + + pingService.pingUrl = function($scope, url, callback) { + if (pingCache[url]) { + invokeCallback($scope, pingCache[url]['pings'], callback); + return; + } + + // Note: We do each in a callback after 1s to prevent it running when other code + // runs (which can skew the results). + setTimeout(function() { + pingUrlInternal($scope, url, callback); + }, 1000); + }; + + return pingService; +}]); diff --git a/static/js/services/plan-service.js b/static/js/services/plan-service.js new file mode 100644 index 000000000..ee06992e5 --- /dev/null +++ b/static/js/services/plan-service.js @@ -0,0 +1,357 @@ +/** + * Helper service for loading, changing and working with subscription plans. + */ +angular.module('quay') + .factory('PlanService', ['KeyService', 'UserService', 'CookieService', 'ApiService', 'Features', 'Config', + +function(KeyService, UserService, CookieService, ApiService, Features, Config) { + var plans = null; + var planDict = {}; + var planService = {}; + var listeners = []; + + var previousSubscribeFailure = false; + + planService.getFreePlan = function() { + return 'free'; + }; + + planService.registerListener = function(obj, callback) { + listeners.push({'obj': obj, 'callback': callback}); + }; + + planService.unregisterListener = function(obj) { + for (var i = 0; i < listeners.length; ++i) { + if (listeners[i].obj == obj) { + listeners.splice(i, 1); + break; + } + } + }; + + planService.notePlan = function(planId) { + if (Features.BILLING) { + CookieService.putSession('quay.notedplan', planId); + } + }; + + planService.isOrgCompatible = function(plan) { + return plan['stripeId'] == planService.getFreePlan() || plan['bus_features']; + }; + + planService.getMatchingBusinessPlan = function(callback) { + planService.getPlans(function() { + planService.getSubscription(null, function(sub) { + var plan = planDict[sub.plan]; + if (!plan) { + planService.getMinimumPlan(0, true, callback); + return; + } + + var count = Math.max(sub.usedPrivateRepos, plan.privateRepos); + planService.getMinimumPlan(count, true, callback); + }, function() { + planService.getMinimumPlan(0, true, callback); + }); + }); + }; + + planService.handleNotedPlan = function() { + var planId = planService.getAndResetNotedPlan(); + if (!planId || !Features.BILLING) { return false; } + + UserService.load(function() { + if (UserService.currentUser().anonymous) { + return; + } + + planService.getPlan(planId, function(plan) { + if (planService.isOrgCompatible(plan)) { + document.location = '/organizations/new/?plan=' + planId; + } else { + document.location = '/user?plan=' + planId; + } + }); + }); + + return true; + }; + + planService.getAndResetNotedPlan = function() { + var planId = CookieService.get('quay.notedplan'); + CookieService.clear('quay.notedplan'); + return planId; + }; + + planService.handleCardError = function(resp) { + if (!planService.isCardError(resp)) { return; } + + bootbox.dialog({ + "message": resp.data.carderror, + "title": "Credit card issue", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }; + + planService.isCardError = function(resp) { + return resp && resp.data && resp.data.carderror; + }; + + planService.verifyLoaded = function(callback) { + if (!Features.BILLING) { return; } + + if (plans && plans.length) { + callback(plans); + return; + } + + ApiService.listPlans().then(function(data) { + plans = data.plans || []; + for(var i = 0; i < plans.length; i++) { + planDict[plans[i].stripeId] = plans[i]; + } + callback(plans); + }, function() { callback([]); }); + }; + + planService.getPlans = function(callback, opt_includePersonal) { + planService.verifyLoaded(function() { + var filtered = []; + for (var i = 0; i < plans.length; ++i) { + var plan = plans[i]; + if (plan['deprecated']) { continue; } + if (!opt_includePersonal && !planService.isOrgCompatible(plan)) { continue; } + filtered.push(plan); + } + callback(filtered); + }); + }; + + planService.getPlan = function(planId, callback) { + planService.getPlanIncludingDeprecated(planId, function(plan) { + if (!plan['deprecated']) { + callback(plan); + } + }); + }; + + planService.getPlanIncludingDeprecated = function(planId, callback) { + planService.verifyLoaded(function() { + if (planDict[planId]) { + callback(planDict[planId]); + } + }); + }; + + planService.getMinimumPlan = function(privateCount, isBusiness, callback) { + planService.getPlans(function(plans) { + for (var i = 0; i < plans.length; i++) { + var plan = plans[i]; + if (plan.privateRepos >= privateCount) { + callback(plan); + return; + } + } + + callback(null); + }, /* include personal */!isBusiness); + }; + + planService.getSubscription = function(orgname, success, failure) { + if (!Features.BILLING) { return; } + + ApiService.getSubscription(orgname).then(success, failure); + }; + + planService.setSubscription = function(orgname, planId, success, failure, opt_token) { + if (!Features.BILLING) { return; } + + var subscriptionDetails = { + plan: planId + }; + + if (opt_token) { + subscriptionDetails['token'] = opt_token.id; + } + + ApiService.updateSubscription(orgname, subscriptionDetails).then(function(resp) { + success(resp); + planService.getPlan(planId, function(plan) { + for (var i = 0; i < listeners.length; ++i) { + listeners[i]['callback'](plan); + } + }); + }, failure); + }; + + planService.getCardInfo = function(orgname, callback) { + if (!Features.BILLING) { return; } + + ApiService.getCard(orgname).then(function(resp) { + callback(resp.card); + }, function() { + callback({'is_valid': false}); + }); + }; + + planService.changePlan = function($scope, orgname, planId, callbacks, opt_async) { + if (!Features.BILLING) { return; } + + if (callbacks['started']) { + callbacks['started'](); + } + + planService.getPlan(planId, function(plan) { + if (orgname && !planService.isOrgCompatible(plan)) { return; } + + planService.getCardInfo(orgname, function(cardInfo) { + if (plan.price > 0 && (previousSubscribeFailure || !cardInfo.last4)) { + var title = cardInfo.last4 ? 'Subscribe' : 'Start Trial ({{amount}} plan)'; + planService.showSubscribeDialog($scope, orgname, planId, callbacks, title, /* async */true); + return; + } + + previousSubscribeFailure = false; + + planService.setSubscription(orgname, planId, callbacks['success'], function(resp) { + previousSubscribeFailure = true; + planService.handleCardError(resp); + callbacks['failure'](resp); + }); + }); + }); + }; + + planService.changeCreditCard = function($scope, orgname, callbacks) { + if (!Features.BILLING) { return; } + + if (callbacks['opening']) { + callbacks['opening'](); + } + + var submitted = false; + var submitToken = function(token) { + if (submitted) { return; } + submitted = true; + $scope.$apply(function() { + if (callbacks['started']) { + callbacks['started'](); + } + + var cardInfo = { + 'token': token.id + }; + + ApiService.setCard(orgname, cardInfo).then(callbacks['success'], function(resp) { + planService.handleCardError(resp); + callbacks['failure'](resp); + }); + }); + }; + + var email = planService.getEmail(orgname); + StripeCheckout.open({ + key: KeyService.stripePublishableKey, + address: false, + email: email, + currency: 'usd', + name: 'Update credit card', + description: 'Enter your credit card number', + panelLabel: 'Update', + token: submitToken, + image: 'static/img/quay-icon-stripe.png', + opened: function() { $scope.$apply(function() { callbacks['opened']() }); }, + closed: function() { $scope.$apply(function() { callbacks['closed']() }); } + }); + }; + + planService.getEmail = function(orgname) { + var email = null; + if (UserService.currentUser()) { + email = UserService.currentUser().email; + + if (orgname) { + org = UserService.getOrganization(orgname); + if (org) { + emaiil = org.email; + } + } + } + return email; + }; + + planService.showSubscribeDialog = function($scope, orgname, planId, callbacks, opt_title, opt_async) { + if (!Features.BILLING) { return; } + + // If the async parameter is true and this is a browser that does not allow async popup of the + // Stripe dialog (such as Mobile Safari or IE), show a bootbox to show the dialog instead. + var isIE = navigator.appName.indexOf("Internet Explorer") != -1; + var isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/); + + if (opt_async && (isIE || isMobileSafari)) { + bootbox.dialog({ + "message": "Please click 'Subscribe' to continue", + "buttons": { + "subscribe": { + "label": "Subscribe", + "className": "btn-primary", + "callback": function() { + planService.showSubscribeDialog($scope, orgname, planId, callbacks, opt_title, false); + } + }, + "close": { + "label": "Cancel", + "className": "btn-default" + } + } + }); + return; + } + + if (callbacks['opening']) { + callbacks['opening'](); + } + + var submitted = false; + var submitToken = function(token) { + if (submitted) { return; } + submitted = true; + + if (Config.MIXPANEL_KEY) { + mixpanel.track('plan_subscribe'); + } + + $scope.$apply(function() { + if (callbacks['started']) { + callbacks['started'](); + } + planService.setSubscription(orgname, planId, callbacks['success'], callbacks['failure'], token); + }); + }; + + planService.getPlan(planId, function(planDetails) { + var email = planService.getEmail(orgname); + StripeCheckout.open({ + key: KeyService.stripePublishableKey, + address: false, + email: email, + amount: planDetails.price, + currency: 'usd', + name: 'Quay.io ' + planDetails.title + ' Subscription', + description: 'Up to ' + planDetails.privateRepos + ' private repositories', + panelLabel: opt_title || 'Subscribe', + token: submitToken, + image: 'static/img/quay-icon-stripe.png', + opened: function() { $scope.$apply(function() { callbacks['opened']() }); }, + closed: function() { $scope.$apply(function() { callbacks['closed']() }); } + }); + }); + }; + + return planService; +}]); \ No newline at end of file diff --git a/static/js/services/string-builder-service.js b/static/js/services/string-builder-service.js new file mode 100644 index 000000000..7b0053c57 --- /dev/null +++ b/static/js/services/string-builder-service.js @@ -0,0 +1,114 @@ +/** + * Service for building strings, with wildcards replaced with metadata. + */ +angular.module('quay').factory('StringBuilderService', ['$sce', 'UtilService', function($sce, UtilService) { + var stringBuilderService = {}; + + stringBuilderService.buildUrl = function(value_or_func, metadata) { + var url = value_or_func; + if (typeof url != 'string') { + url = url(metadata); + } + + // Find the variables to be replaced. + var varNames = []; + for (var i = 0; i < url.length; ++i) { + var c = url[i]; + if (c == '{') { + for (var j = i + 1; j < url.length; ++j) { + var d = url[j]; + if (d == '}') { + varNames.push(url.substring(i + 1, j)); + i = j; + break; + } + } + } + } + + // Replace all variables found. + for (var i = 0; i < varNames.length; ++i) { + var varName = varNames[i]; + if (!metadata[varName]) { + return null; + } + + url = url.replace('{' + varName + '}', metadata[varName]); + } + + return url; + }; + + stringBuilderService.buildString = function(value_or_func, metadata) { + var fieldIcons = { + 'inviter': 'user', + 'username': 'user', + 'user': 'user', + 'email': 'envelope', + 'activating_username': 'user', + 'delegate_user': 'user', + 'delegate_team': 'group', + 'team': 'group', + 'token': 'key', + 'repo': 'hdd-o', + 'robot': 'wrench', + 'tag': 'tag', + 'role': 'th-large', + 'original_role': 'th-large', + 'application_name': 'cloud', + 'image': 'archive', + 'original_image': 'archive', + 'client_id': 'chain' + }; + + var filters = { + 'obj': function(value) { + if (!value) { return []; } + return Object.getOwnPropertyNames(value); + }, + + 'updated_tags': function(value) { + if (!value) { return []; } + return Object.getOwnPropertyNames(value); + } + }; + + var description = value_or_func; + if (typeof description != 'string') { + description = description(metadata); + } + + for (var key in metadata) { + if (metadata.hasOwnProperty(key)) { + var value = metadata[key] != null ? metadata[key] : '(Unknown)'; + if (filters[key]) { + value = filters[key](value); + } + + if (Array.isArray(value)) { + value = value.join(', '); + } + + value = value.toString(); + + if (key.indexOf('image') >= 0) { + value = value.substr(0, 12); + } + + var safe = UtilService.escapeHtmlString(value); + var markedDown = UtilService.getMarkedDown(value); + markedDown = markedDown.substr('

'.length, markedDown.length - '

'.length); + + var icon = fieldIcons[key]; + if (icon) { + markedDown = '' + markedDown; + } + + description = description.replace('{' + key + '}', '' + markedDown + ''); + } + } + return $sce.trustAsHtml(description.replace('\n', '
')); + }; + + return stringBuilderService; +}]); diff --git a/static/js/services/trigger-service.js b/static/js/services/trigger-service.js new file mode 100644 index 000000000..4a060866c --- /dev/null +++ b/static/js/services/trigger-service.js @@ -0,0 +1,65 @@ +/** + * Helper service for defining the various kinds of build triggers and retrieving information + * about them. + */ +angular.module('quay').factory('TriggerService', ['UtilService', '$sanitize', 'KeyService', + function(UtilService, $sanitize, KeyService) { + var triggerService = {}; + + var triggerTypes = { + 'github': { + 'description': function(config) { + var source = UtilService.textToSafeHtml(config['build_source']); + var desc = ' Push to Github Repository '; + desc += '' + source + ''; + desc += '
Dockerfile folder: //' + UtilService.textToSafeHtml(config['subdir']); + return desc; + }, + + 'run_parameters': [ + { + 'title': 'Branch', + 'type': 'option', + 'name': 'branch_name' + } + ], + + 'get_redirect_url': function(namespace, repository) { + var redirect_uri = KeyService['githubRedirectUri'] + '/trigger/' + + namespace + '/' + repository; + + var authorize_url = KeyService['githubTriggerAuthorizeUrl']; + var client_id = KeyService['githubTriggerClientId']; + + return authorize_url + 'client_id=' + client_id + + '&scope=repo,user:email&redirect_uri=' + redirect_uri; + } + } + } + + triggerService.getRedirectUrl = function(name, namespace, repository) { + var type = triggerTypes[name]; + if (!type) { + return ''; + } + return type['get_redirect_url'](namespace, repository); + }; + + triggerService.getDescription = function(name, config) { + var type = triggerTypes[name]; + if (!type) { + return 'Unknown'; + } + return type['description'](config); + }; + + triggerService.getRunParameters = function(name, config) { + var type = triggerTypes[name]; + if (!type) { + return []; + } + return type['run_parameters']; + } + + return triggerService; +}]); diff --git a/static/js/services/ui-service.js b/static/js/services/ui-service.js new file mode 100644 index 000000000..f6872a5ba --- /dev/null +++ b/static/js/services/ui-service.js @@ -0,0 +1,37 @@ +/** + * Service which provides helper methods for performing some simple UI operations. + */ +angular.module('quay').factory('UIService', [function() { + var uiService = {}; + + uiService.hidePopover = function(elem) { + var popover = $(elem).data('bs.popover'); + if (popover) { + popover.hide(); + } + }; + + uiService.showPopover = function(elem, content) { + var popover = $(elem).data('bs.popover'); + if (!popover) { + $(elem).popover({'content': '-', 'placement': 'left'}); + } + + setTimeout(function() { + var popover = $(elem).data('bs.popover'); + popover.options.content = content; + popover.show(); + }, 500); + }; + + uiService.showFormError = function(elem, result) { + var message = result.data['message'] || result.data['error_description'] || ''; + if (message) { + uiService.showPopover(elem, message); + } else { + uiService.hidePopover(elem); + } + }; + + return uiService; +}]); diff --git a/static/js/services/user-service.js b/static/js/services/user-service.js new file mode 100644 index 000000000..7f5ee4463 --- /dev/null +++ b/static/js/services/user-service.js @@ -0,0 +1,131 @@ +/** + * Service which monitors the current user session and provides methods for returning information + * about the user. + */ +angular.module('quay') + .factory('UserService', ['ApiService', 'CookieService', '$rootScope', 'Config', + +function(ApiService, CookieService, $rootScope, Config) { + var userResponse = { + verified: false, + anonymous: true, + username: null, + email: null, + organizations: [], + logins: [] + } + + var userService = {} + + userService.hasEverLoggedIn = function() { + return CookieService.get('quay.loggedin') == 'true'; + }; + + userService.updateUserIn = function(scope, opt_callback) { + scope.$watch(function () { return userService.currentUser(); }, function (currentUser) { + scope.user = currentUser; + if (opt_callback) { + opt_callback(currentUser); + } + }, true); + }; + + userService.load = function(opt_callback) { + var handleUserResponse = function(loadedUser) { + userResponse = loadedUser; + + if (!userResponse.anonymous) { + if (Config.MIXPANEL_KEY) { + mixpanel.identify(userResponse.username); + mixpanel.people.set({ + '$email': userResponse.email, + '$username': userResponse.username, + 'verified': userResponse.verified + }); + mixpanel.people.set_once({ + '$created': new Date() + }) + } + + if (window.olark !== undefined) { + olark('api.visitor.getDetails', function(details) { + if (details.fullName === null) { + olark('api.visitor.updateFullName', {fullName: userResponse.username}); + } + }); + olark('api.visitor.updateEmailAddress', {emailAddress: userResponse.email}); + olark('api.chat.updateVisitorStatus', {snippet: 'username: ' + userResponse.username}); + } + + if (window.Raven !== undefined) { + Raven.setUser({ + email: userResponse.email, + id: userResponse.username + }); + } + + CookieService.putPermanent('quay.loggedin', 'true'); + } else { + if (window.Raven !== undefined) { + Raven.setUser(); + } + } + + if (opt_callback) { + opt_callback(); + } + }; + + ApiService.getLoggedInUser().then(function(loadedUser) { + handleUserResponse(loadedUser); + }, function() { + handleUserResponse({'anonymous': true}); + }); + }; + + userService.getOrganization = function(name) { + if (!userResponse || !userResponse.organizations) { return null; } + for (var i = 0; i < userResponse.organizations.length; ++i) { + var org = userResponse.organizations[i]; + if (org.name == name) { + return org; + } + } + + return null; + }; + + userService.isNamespaceAdmin = function(namespace) { + if (namespace == userResponse.username) { + return true; + } + + var org = userService.getOrganization(namespace); + if (!org) { + return false; + } + + return org.is_org_admin; + }; + + userService.isKnownNamespace = function(namespace) { + if (namespace == userResponse.username) { + return true; + } + + var org = userService.getOrganization(namespace); + return !!org; + }; + + userService.currentUser = function() { + return userResponse; + }; + + // Update the user in the root scope. + userService.updateUserIn($rootScope); + + // Load the user the first time. + userService.load(); + + return userService; +}]); diff --git a/static/js/services/util-service.js b/static/js/services/util-service.js new file mode 100644 index 000000000..195097b08 --- /dev/null +++ b/static/js/services/util-service.js @@ -0,0 +1,88 @@ +/** + * Service which exposes various utility methods. + */ +angular.module('quay').factory('UtilService', ['$sanitize', function($sanitize) { + var utilService = {}; + + utilService.isEmailAddress = function(val) { + var emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; + return emailRegex.test(val); + }; + + utilService.getMarkedDown = function(string) { + return Markdown.getSanitizingConverter().makeHtml(string || ''); + }; + + utilService.getFirstMarkdownLineAsText = function(commentString) { + if (!commentString) { return ''; } + + var lines = commentString.split('\n'); + var MARKDOWN_CHARS = { + '#': true, + '-': true, + '>': true, + '`': true + }; + + for (var i = 0; i < lines.length; ++i) { + // Skip code lines. + if (lines[i].indexOf(' ') == 0) { + continue; + } + + // Skip empty lines. + if ($.trim(lines[i]).length == 0) { + continue; + } + + // Skip control lines. + if (MARKDOWN_CHARS[$.trim(lines[i])[0]]) { + continue; + } + + return utilService.getMarkedDown(lines[i]); + } + + return ''; + }; + + utilService.escapeHtmlString = function(text) { + var adjusted = text.replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + + return adjusted; + }; + + utilService.getRestUrl = function(args) { + var url = ''; + for (var i = 0; i < arguments.length; ++i) { + if (i > 0) { + url += '/'; + } + url += encodeURI(arguments[i]) + } + return url; + }; + + utilService.textToSafeHtml = function(text) { + return $sanitize(utilService.escapeHtmlString(text)); + }; + + utilService.clickElement = function(el) { + // From: http://stackoverflow.com/questions/16802795/click-not-working-in-mocha-phantomjs-on-certain-elements + var ev = document.createEvent("MouseEvent"); + ev.initMouseEvent( + "click", + true /* bubble */, true /* cancelable */, + window, null, + 0, 0, 0, 0, /* coordinates */ + false, false, false, false, /* modifier keys */ + 0 /*left*/, null); + el.dispatchEvent(ev); + }; + + return utilService; +}]); diff --git a/static/partials/exp-new-layout.html b/static/partials/exp-new-layout.html new file mode 100644 index 000000000..e9b7edaa0 --- /dev/null +++ b/static/partials/exp-new-layout.html @@ -0,0 +1,10 @@ +
+
+ + Experiment: New Layout +
+
+ + +
+
diff --git a/static/partials/organizations.html b/static/partials/organizations.html index 43cbf1944..b556ed300 100644 --- a/static/partials/organizations.html +++ b/static/partials/organizations.html @@ -10,7 +10,7 @@ Create New Organization - +