From 9b87999c1c472ea9fcceac59352a4aa38515e868 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 19 Feb 2015 16:21:54 -0500 Subject: [PATCH] Code cleanup part #1: move all the services and directive JS code in the app.js file into its own files --- static/directives/prototype-manager.html | 2 +- static/js/app.js | 6958 +---------------- static/js/controllers.js | 20 +- static/js/directives/fallback-src.js | 14 + static/js/directives/file-present.js | 18 + static/js/directives/filters/bytes.js | 12 + static/js/directives/filters/regex.js | 23 + static/js/directives/filters/reverse.js | 8 + .../directives/filters/visible-log-filter.js | 19 + .../directives/focusable-popover-content.js | 37 + static/js/directives/match.js | 16 + static/js/directives/ng-blur.js | 7 + static/js/directives/ng-if-media.js | 15 + static/js/directives/ng-visible.js | 10 + static/js/directives/onresize.js | 20 + static/js/directives/quay-layout.js | 170 + static/js/directives/ui/application-info.js | 18 + .../js/directives/ui/application-manager.js | 56 + .../js/directives/ui/application-reference.js | 36 + static/js/directives/ui/avatar.js | 34 + static/js/directives/ui/billing-invoice.js | 49 + static/js/directives/ui/billing-options.js | 113 + static/js/directives/ui/build-log-command.js | 26 + static/js/directives/ui/build-log-error.js | 62 + static/js/directives/ui/build-log-phase.js | 18 + static/js/directives/ui/build-message.js | 61 + static/js/directives/ui/build-progress.js | 53 + static/js/directives/ui/build-status.js | 18 + static/js/directives/ui/copy-box.js | 76 + .../ui/create-external-notification-dialog.js | 144 + static/js/directives/ui/delete-ui.js | 26 + static/js/directives/ui/docker-auth-dialog.js | 86 + .../directives/ui/dockerfile-build-dialog.js | 45 + .../js/directives/ui/dockerfile-build-form.js | 199 + static/js/directives/ui/dockerfile-command.js | 68 + static/js/directives/ui/dockerfile-view.js | 38 + static/js/directives/ui/dropdown-select.js | 174 + static/js/directives/ui/entity-reference.js | 56 + static/js/directives/ui/entity-search.js | 361 + .../js/directives/ui/external-login-button.js | 40 + .../ui/external-notification-view.js | 59 + static/js/directives/ui/header-bar.js | 45 + static/js/directives/ui/location-view.js | 106 + static/js/directives/ui/logs-view.js | 336 + .../ui/manual-trigger-build-dialog.js | 61 + static/js/directives/ui/markdown-input.js | 49 + static/js/directives/ui/markdown-view.js | 25 + static/js/directives/ui/namespace-selector.js | 74 + static/js/directives/ui/notification-view.js | 69 + .../js/directives/ui/notifications-bubble.js | 19 + .../js/directives/ui/organization-header.js | 20 + static/js/directives/ui/plan-manager.js | 112 + static/js/directives/ui/plans-table.js | 23 + static/js/directives/ui/popup-input-button.js | 48 + static/js/directives/ui/prototype-manager.js | 123 + static/js/directives/ui/ps-usage-graph.js | 50 + static/js/directives/ui/quay-spinner.js | 16 + static/js/directives/ui/registry-name.js | 20 + static/js/directives/ui/repo-breadcrumb.js | 22 + static/js/directives/ui/repo-circle.js | 18 + static/js/directives/ui/repo-search.js | 76 + static/js/directives/ui/resource-view.js | 20 + static/js/directives/ui/robots-manager.js | 102 + static/js/directives/ui/role-group.js | 29 + .../js/directives/ui/setup-trigger-dialog.js | 136 + static/js/directives/ui/signin-form.js | 99 + static/js/directives/ui/signup-form.js | 51 + static/js/directives/ui/step-view.js | 120 + .../directives/ui/tag-specific-image-view.js | 127 + static/js/directives/ui/tour-content.js | 35 + .../js/directives/ui/trigger-description.js | 21 + .../js/directives/ui/trigger-setup-github.js | 219 + static/js/directives/ui/twitter-view.js | 22 + static/js/directives/ui/usage-chart.js | 50 + static/js/directives/ui/user-setup.js | 47 + static/js/services/angular-helper.js | 42 + static/js/services/angular-poll-channel.js | 72 + static/js/services/angular-view-array.js | 87 + static/js/services/api-service.js | 330 + static/js/services/avatar-service.js | 48 + static/js/services/container-service.js | 36 + static/js/services/cookie-service.js | 23 + static/js/services/create-service.js | 28 + static/js/services/datafile-service.js | 170 + .../js/services/external-notification-data.js | 166 + static/js/services/features-config.js | 71 + static/js/services/image-metadata-service.js | 28 + static/js/services/key-service.js | 63 + static/js/services/notification-service.js | 228 + static/js/services/oauth-service.js | 8 + static/js/services/ping-service.js | 99 + static/js/services/plan-service.js | 357 + static/js/services/string-builder-service.js | 114 + static/js/services/trigger-service.js | 65 + static/js/services/ui-service.js | 37 + static/js/services/user-service.js | 131 + static/js/services/util-service.js | 88 + 97 files changed, 7076 insertions(+), 6870 deletions(-) create mode 100644 static/js/directives/fallback-src.js create mode 100644 static/js/directives/file-present.js create mode 100644 static/js/directives/filters/bytes.js create mode 100644 static/js/directives/filters/regex.js create mode 100644 static/js/directives/filters/reverse.js create mode 100644 static/js/directives/filters/visible-log-filter.js create mode 100644 static/js/directives/focusable-popover-content.js create mode 100644 static/js/directives/match.js create mode 100644 static/js/directives/ng-blur.js create mode 100644 static/js/directives/ng-if-media.js create mode 100644 static/js/directives/ng-visible.js create mode 100644 static/js/directives/onresize.js create mode 100644 static/js/directives/quay-layout.js create mode 100644 static/js/directives/ui/application-info.js create mode 100644 static/js/directives/ui/application-manager.js create mode 100644 static/js/directives/ui/application-reference.js create mode 100644 static/js/directives/ui/avatar.js create mode 100644 static/js/directives/ui/billing-invoice.js create mode 100644 static/js/directives/ui/billing-options.js create mode 100644 static/js/directives/ui/build-log-command.js create mode 100644 static/js/directives/ui/build-log-error.js create mode 100644 static/js/directives/ui/build-log-phase.js create mode 100644 static/js/directives/ui/build-message.js create mode 100644 static/js/directives/ui/build-progress.js create mode 100644 static/js/directives/ui/build-status.js create mode 100644 static/js/directives/ui/copy-box.js create mode 100644 static/js/directives/ui/create-external-notification-dialog.js create mode 100644 static/js/directives/ui/delete-ui.js create mode 100644 static/js/directives/ui/docker-auth-dialog.js create mode 100644 static/js/directives/ui/dockerfile-build-dialog.js create mode 100644 static/js/directives/ui/dockerfile-build-form.js create mode 100644 static/js/directives/ui/dockerfile-command.js create mode 100644 static/js/directives/ui/dockerfile-view.js create mode 100644 static/js/directives/ui/dropdown-select.js create mode 100644 static/js/directives/ui/entity-reference.js create mode 100644 static/js/directives/ui/entity-search.js create mode 100644 static/js/directives/ui/external-login-button.js create mode 100644 static/js/directives/ui/external-notification-view.js create mode 100644 static/js/directives/ui/header-bar.js create mode 100644 static/js/directives/ui/location-view.js create mode 100644 static/js/directives/ui/logs-view.js create mode 100644 static/js/directives/ui/manual-trigger-build-dialog.js create mode 100644 static/js/directives/ui/markdown-input.js create mode 100644 static/js/directives/ui/markdown-view.js create mode 100644 static/js/directives/ui/namespace-selector.js create mode 100644 static/js/directives/ui/notification-view.js create mode 100644 static/js/directives/ui/notifications-bubble.js create mode 100644 static/js/directives/ui/organization-header.js create mode 100644 static/js/directives/ui/plan-manager.js create mode 100644 static/js/directives/ui/plans-table.js create mode 100644 static/js/directives/ui/popup-input-button.js create mode 100644 static/js/directives/ui/prototype-manager.js create mode 100644 static/js/directives/ui/ps-usage-graph.js create mode 100644 static/js/directives/ui/quay-spinner.js create mode 100644 static/js/directives/ui/registry-name.js create mode 100644 static/js/directives/ui/repo-breadcrumb.js create mode 100644 static/js/directives/ui/repo-circle.js create mode 100644 static/js/directives/ui/repo-search.js create mode 100644 static/js/directives/ui/resource-view.js create mode 100644 static/js/directives/ui/robots-manager.js create mode 100644 static/js/directives/ui/role-group.js create mode 100644 static/js/directives/ui/setup-trigger-dialog.js create mode 100644 static/js/directives/ui/signin-form.js create mode 100644 static/js/directives/ui/signup-form.js create mode 100644 static/js/directives/ui/step-view.js create mode 100644 static/js/directives/ui/tag-specific-image-view.js create mode 100644 static/js/directives/ui/tour-content.js create mode 100644 static/js/directives/ui/trigger-description.js create mode 100644 static/js/directives/ui/trigger-setup-github.js create mode 100644 static/js/directives/ui/twitter-view.js create mode 100644 static/js/directives/ui/usage-chart.js create mode 100644 static/js/directives/ui/user-setup.js create mode 100644 static/js/services/angular-helper.js create mode 100644 static/js/services/angular-poll-channel.js create mode 100644 static/js/services/angular-view-array.js create mode 100644 static/js/services/api-service.js create mode 100644 static/js/services/avatar-service.js create mode 100644 static/js/services/container-service.js create mode 100644 static/js/services/cookie-service.js create mode 100644 static/js/services/create-service.js create mode 100644 static/js/services/datafile-service.js create mode 100644 static/js/services/external-notification-data.js create mode 100644 static/js/services/features-config.js create mode 100644 static/js/services/image-metadata-service.js create mode 100644 static/js/services/key-service.js create mode 100644 static/js/services/notification-service.js create mode 100644 static/js/services/oauth-service.js create mode 100644 static/js/services/ping-service.js create mode 100644 static/js/services/plan-service.js create mode 100644 static/js/services/string-builder-service.js create mode 100644 static/js/services/trigger-service.js create mode 100644 static/js/services/ui-service.js create mode 100644 static/js/services/user-service.js create mode 100644 static/js/services/util-service.js diff --git a/static/directives/prototype-manager.html b/static/directives/prototype-manager.html index 68aaaffc7..85e897318 100644 --- a/static/directives/prototype-manager.html +++ b/static/directives/prototype-manager.html @@ -1,7 +1,7 @@
-
+
Default permissions provide a means of specifying additional permissions that should be granted automatically to a repository when it is created.
diff --git a/static/js/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; +}]);