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/app.js b/static/js/app.js
index 43fa6fc5c..14c4d2f65 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -2,128 +2,6 @@ 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;
- }
-
- 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) {
- return Markdown.getSanitizingConverter().makeHtml(string || '');
-}
-
-
quayDependencies = ['ngRoute', 'chieffancypants.loadingBar', 'angular-tour', 'restangular', 'angularMoment',
'mgcrea.ngStrap', 'ngCookies', 'ngSanitize', 'angular-md5', 'pasvaz.bindonce', 'ansiToHtml',
'ngAnimate', 'core-ui', 'core-config-setup'];
@@ -133,6747 +11,111 @@ if (window.__config && window.__config.MIXPANEL_KEY) {
quayDependencies.push('angulartics.mixpanel');
}
+// Define the application.
quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoadingBarProvider) {
- 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;
-
- 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;
- }]);
-
- /**
- * 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;
-
- 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;
- }]);
-
- $provide.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;
- }]);
-
-
- $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;
+ cfpLoadingBarProvider.includeSpinner = false;
+});
+
+// Configure the routes.
+quayApp.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'}).
+ otherwise({redirectTo: '/'});
+}]);
+
+// 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):/);
+});
+
+// Configure the API provider.
+quayApp.config(function(RestangularProvider) {
+ RestangularProvider.setBaseUrl('/api/v1/');
+});
+
+// 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'}).
- 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;
- };
-});
-
-
-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) {
+// 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) {
// Handle session security.
Restangular.setDefaultRequestParams(['post', 'put', 'remove', 'delete'], {'_csrf_token': window.__token || ''});
@@ -6928,7 +170,7 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi
return;
}
- clickElement(this);
+ UtilService.clickElement(this);
});
}, opt_timeout);
};
@@ -6937,7 +179,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);
}
});
});
diff --git a/static/js/controllers.js b/static/js/controllers.js
index 61c624fce..05cde373b 100644
--- a/static/js/controllers.js
+++ b/static/js/controllers.js
@@ -401,7 +401,7 @@ function LandingCtrl($scope, UserService, ApiService, Features, Config) {
};
}
-function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiService, $routeParams, $rootScope, $location, $timeout, Config) {
+function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiService, $routeParams, $rootScope, $location, $timeout, Config, UtilService) {
$scope.Config = Config;
var namespace = $routeParams.namespace;
@@ -718,8 +718,6 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
$scope.updatePullCommand();
};
- $scope.getFirstTextLine = getFirstTextLine;
-
$scope.getTagCount = function(repo) {
if (!repo) { return 0; }
var count = 0;
@@ -809,7 +807,7 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
var qualifiedRepoName = namespace + '/' + name;
$rootScope.title = qualifiedRepoName;
var kind = repo.is_public ? 'public' : 'private';
- $rootScope.description = jQuery(getFirstTextLine(repo.description)).text() ||
+ $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.
@@ -890,7 +888,7 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi
// Create the new tree.
var tree = new ImageHistoryTree(namespace, name, resp.images,
- getFirstTextLine, $scope.getTimeSince, ImageMetadataService.getEscapedFormattedCommand);
+ UtilService.getFirstMarkdownLineAsText, $scope.getTimeSince, ImageMetadataService.getEscapedFormattedCommand);
$scope.tree = tree.draw('image-history-container');
if ($scope.tree) {
@@ -1073,7 +1071,7 @@ function BuildPackageCtrl($scope, Restangular, ApiService, DataFileService, $rou
}
function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, TriggerService, $routeParams,
- $rootScope, $location, UserService, Config, Features, ExternalNotificationData) {
+ $rootScope, $location, UserService, Config, Features, ExternalNotificationData, UtilService) {
var namespace = $routeParams.namespace;
var name = $routeParams.name;
@@ -1159,7 +1157,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, TriggerServi
}
});
- var permissionDelete = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName));
+ var permissionDelete = Restangular.one(UtilService.getRestUrl('repository', namespace, name, 'permissions', kind, entityName));
permissionDelete.customDELETE().then(function() {
delete $scope.permissions[kind][entityName];
}, errorHandler);
@@ -1170,7 +1168,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, TriggerServi
'role': role,
};
- var permissionPost = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName));
+ 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'));
@@ -1187,7 +1185,7 @@ function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, TriggerServi
var currentRole = permission.role;
permission.role = role;
- var permissionPut = Restangular.one(getRestUrl('repository', namespace, name, 'permissions', kind, entityName));
+ 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;
@@ -1957,7 +1955,7 @@ function NewRepoCtrl($scope, $location, $http, $timeout, UserService, ApiService
};
}
-function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams) {
+function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams, CreateService) {
var orgname = $routeParams.orgname;
$scope.TEAM_PATTERN = TEAM_PATTERN;
@@ -2001,7 +1999,7 @@ function OrgViewCtrl($rootScope, $scope, ApiService, $routeParams) {
return;
}
- createOrganizationTeam(ApiService, orgname, teamname, function(created) {
+ CreateService.createOrganizationTeam(ApiService, orgname, teamname, function(created) {
$scope.organization.teams[teamname] = created;
});
};
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/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/services/angular-helper.js b/static/js/services/angular-helper.js
new file mode 100644
index 000000000..ed74ec3c1
--- /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() {
+ 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;
+}]);