diff --git a/endpoints/api/build.py b/endpoints/api/build.py index 69e23efae..e52962416 100644 --- a/endpoints/api/build.py +++ b/endpoints/api/build.py @@ -94,7 +94,11 @@ def build_status_view(build_obj, can_write=False): 'is_writer': can_write, 'trigger': trigger_view(build_obj.trigger), 'resource_key': build_obj.resource_key, - 'pull_robot': user_view(build_obj.pull_robot) if build_obj.pull_robot else None + 'pull_robot': user_view(build_obj.pull_robot) if build_obj.pull_robot else None, + 'repository': { + 'namespace': build_obj.repository.namespace_user.username, + 'name': build_obj.repository.name + } } if can_write: @@ -215,6 +219,18 @@ class RepositoryBuildList(RepositoryParamResource): @path_param('build_uuid', 'The UUID of the build') class RepositoryBuildResource(RepositoryParamResource): """ Resource for dealing with repository builds. """ + @require_repo_read + @nickname('getRepoBuild') + def get(self, namespace, repository, build_uuid): + """ Returns information about a build. """ + try: + build = model.get_repository_build(build_uuid) + except model.InvalidRepositoryBuildException: + raise NotFound() + + can_write = ModifyRepositoryPermission(namespace, repository).can() + return build_status_view(build, can_write) + @require_repo_admin @nickname('cancelRepoBuild') def delete(self, namespace, repository, build_uuid): diff --git a/endpoints/api/trigger.py b/endpoints/api/trigger.py index 1c59be1ee..4788a6e65 100644 --- a/endpoints/api/trigger.py +++ b/endpoints/api/trigger.py @@ -421,7 +421,8 @@ class ActivateBuildTrigger(RepositoryParamResource): pull_robot_name = model.get_pull_robot_name(trigger) build_request = start_build(repo, dockerfile_id, tags, name, subdir, True, - pull_robot_name=pull_robot_name, trigger_metadata=metadata) + trigger=trigger, pull_robot_name=pull_robot_name, + trigger_metadata=metadata) except TriggerStartException as tse: raise InvalidRequest(tse.message) diff --git a/endpoints/common.py b/endpoints/common.py index 9bebbd0c2..e8f49b087 100644 --- a/endpoints/common.py +++ b/endpoints/common.py @@ -224,7 +224,8 @@ def start_build(repository, dockerfile_id, tags, build_name, subdir, manual, 'docker_tags': tags, 'registry': host, 'build_subdir': subdir, - 'trigger_metadata': trigger_metadata or {} + 'trigger_metadata': trigger_metadata or {}, + 'is_manual': manual } with app.config['DB_TRANSACTION_FACTORY'](db): diff --git a/endpoints/trigger.py b/endpoints/trigger.py index cc248f798..bfd9f8ce7 100644 --- a/endpoints/trigger.py +++ b/endpoints/trigger.py @@ -310,6 +310,30 @@ class GithubBuildTrigger(BuildTrigger): message = ge.data.get('message', 'Unable to read Dockerfile: %s' % source) raise RepositoryReadException(message) + @staticmethod + def _build_commit_info(repo, commit_sha): + try: + commit = repo.get_commit(commit_sha) + except GithubException: + logger.exception('Could not load data for commit') + return + + return { + 'url': commit.html_url, + 'message': commit.commit.message, + 'author': { + 'username': commit.author.login, + 'avatar_url': commit.author.avatar_url, + 'url': commit.author.html_url + }, + 'committer': { + 'username': commit.committer.login, + 'avatar_url': commit.committer.avatar_url, + 'url': commit.committer.html_url + }, + 'date': commit.last_modified + } + @staticmethod def _prepare_build(config, repo, commit_sha, build_name, ref): # Prepare the download and upload URLs @@ -360,9 +384,14 @@ class GithubBuildTrigger(BuildTrigger): metadata = { 'commit_sha': commit_sha, 'ref': ref, - 'default_branch': repo.default_branch + 'default_branch': repo.default_branch, } + # add the commit info. + commit_info = GithubBuildTrigger._build_commit_info(repo, commit_sha) + if commit_info is not None: + metadata['commit_info'] = commit_info + return dockerfile_id, list(tags), build_name, joined_subdir, metadata @staticmethod @@ -417,6 +446,7 @@ class GithubBuildTrigger(BuildTrigger): branch_name = run_parameters.get('branch_name') or repo.default_branch branch = repo.get_branch(branch_name) branch_sha = branch.commit.sha + commit_info = branch.commit short_sha = GithubBuildTrigger.get_display_name(branch_sha) ref = 'refs/heads/%s' % (branch_name) diff --git a/initdb.py b/initdb.py index f18093a1b..4076eff09 100644 --- a/initdb.py +++ b/initdb.py @@ -418,6 +418,11 @@ def populate_database(): 'repository': repo, 'docker_tags': ['latest'], 'build_subdir': '', + 'trigger_metadata': { + 'commit_sha': '3482adc5822c498e8f7db2e361e8d57b3d77ddd9', + 'ref': 'refs/heads/master', + 'default_branch': 'master' + } } record = model.create_email_authorization_for_repo(new_user_1.username, 'simple', diff --git a/static/css/core-ui.css b/static/css/core-ui.css index 50b934647..91c0b6607 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -733,4 +733,24 @@ .cor-container { padding-left: 15px; padding-right: 15px; -} \ No newline at end of file +} + +.cor-title-link { + line-height: 30px; + margin-top: 22px; + margin-bottom: 10px; + font-size: 16px; +} + +.cor-title-link a { + color: lightblue; +} + +.cor-title-link a.back-link:before { + content: "\f060"; + color: white; + display: inline-block; + margin-right: 10px; + vertical-align: middle; + font-family: FontAwesome; +} diff --git a/static/css/directives/ui/build-info-bar.css b/static/css/directives/ui/build-info-bar.css new file mode 100644 index 000000000..2421b89a9 --- /dev/null +++ b/static/css/directives/ui/build-info-bar.css @@ -0,0 +1,34 @@ +.build-info-bar-element { + position: relative; + padding: 6px; + padding-left: 20px; +} + +.build-info-bar-element .phase-icon { + position: absolute; + top: 4px; + left: 4px; + bottom: 0px; + height: auto; + border-radius: 0px; + width: 5px; +} + +.build-info-bar-element .build-side-info { + float: right; +} + +.build-info-bar-element .build-side-info .build-side-id { + font-size: 12px; + color: #ccc; + margin-bottom: 10px; +} + +.build-info-bar-element .build-side-info .timing .fa { + display: inline-block; + margin-right: 4px; +} + +.build-info-bar-element .build-side-info .timing { + text-align: right; +} \ No newline at end of file diff --git a/static/css/directives/ui/build-logs-view.css b/static/css/directives/ui/build-logs-view.css new file mode 100644 index 000000000..dca63e410 --- /dev/null +++ b/static/css/directives/ui/build-logs-view.css @@ -0,0 +1,179 @@ +.build-logs-view { + position: relative; + color: white; + padding: 10px; + overflow: auto; + box-shadow: inset 10px 10px 5px -9px rgba(0,0,0,0.75); + background-color: #263945; + + min-height: 100px; +} + +.build-logs-view .no-logs { + color: #8C8C8C; +} + +.build-logs-view .container-header { + padding: 2px; +} + +.build-logs-view .container-logs { + margin: 4px; + padding-bottom: 4px; +} + +.build-logs-view .command-title, +.build-logs-view .log-entry .message, +.build-logs-view .log-entry .message span { + font-family: Consolas, "Lucida Console", Monaco, monospace; + font-size: 13px; +} + +.build-logs-view .container-header { + cursor: pointer; + position: relative; +} + +.build-logs-view .container-header i.fa.chevron { + color: #666; + margin-right: 4px; + width: 14px; + text-align: center; + + position: absolute; + top: 6px; + left: 0px; +} + +.build-logs-view .log-container.command { + margin-left: 22px; +} + +.build-logs-view .container-header.building { + margin-bottom: 10px; +} + +.build-logs-view .container-header.pushing { + margin-top: 10px; +} + +.build-logs-view .build-log-error-element .error-message-container { + position: relative; + display: inline-block; + margin: 10px; + padding: 10px; + background: rgba(255, 0, 0, 0.17); + border-radius: 10px; + margin-left: 22px; +} + +.build-logs-view .build-log-error-element .error-message-container i.fa { + color: red; + position: absolute; + top: 13px; + left: 11px; +} + +.build-logs-view .build-log-error-element .error-message { + display: inline-block; + margin-left: 25px; +} + +.build-logs-view .container-header .label { + padding-top: 4px; + text-align: right; + margin-right: 4px; + width: 86px; + display: inline-block; + + border-right: 4px solid #aaa; + background-color: #717171; + + position: absolute; + top: 4px; + left: 24px; +} + +.build-logs-view .dockerfile-command { + position: inherit; +} + +.build-logs-view .dockerfile-command .command-title { + padding-left: 0px; +} + +.build-logs-view .container-header .container-content { + display: block; + padding-left: 20px; +} + +.build-logs-view .container-header .container-content.build-log-command { + padding-left: 120px; +} + +.build-logs-view .log-entry { + position: relative; +} + +.build-logs-view .log-entry:hover { + background: rgba(0, 0, 0, 0.2); + cursor: pointer; +} + +.build-logs-view .log-entry:hover .timestamp { + visibility: visible; +} + +.build-logs-view .log-entry .timestamp { + float: right; + visibility: hidden; + color: #aaa; + margin-right: 10px; +} + +.build-logs-view .log-entry .message { + display: inline-block; + margin-left: 46px; +} + +.build-logs-view .log-entry .id { + color: #aaa; + padding-right: 6px; + margin-right: 10px; + text-align: right; + font-size: 12px; + width: 40px; + + position: absolute; + top: 1px; + left: 4px; +} + +.build-logs-view .copy-button { + position: absolute; + top: 6px; + right: 6px; + z-index: 2; + transition: all 0.15s ease-in-out; +} + +.build-logs-view .copy-button:not(.zeroclipboard-is-hover) { + background: transparent; + border: 1px solid transparent; + color: #ddd; +} + +.build-logs-view .copy-button i.fa { + margin-right: 10px; +} + +.build-logs-view-element.with-timestamps .log-entry .message { + display: inline-block; + margin-left: 146px; +} + +.build-logs-view-element.with-timestamps .log-entry .id { + width: 140px; + white-space: nowrap; + padding-right: 10px; +} diff --git a/static/css/directives/ui/source-commit-link.css b/static/css/directives/ui/source-commit-link.css new file mode 100644 index 000000000..55966d164 --- /dev/null +++ b/static/css/directives/ui/source-commit-link.css @@ -0,0 +1,4 @@ +.source-commit-link-element .fa { + margin-right: 4px; + display: inline-block; +} \ No newline at end of file diff --git a/static/css/directives/ui/source-ref-link.css b/static/css/directives/ui/source-ref-link.css new file mode 100644 index 000000000..cf593ec07 --- /dev/null +++ b/static/css/directives/ui/source-ref-link.css @@ -0,0 +1,4 @@ +.source-ref-link-element .fa { + margin-right: 4px; + display: inline-block; +} \ No newline at end of file diff --git a/static/css/directives/ui/triggered-build-description.css b/static/css/directives/ui/triggered-build-description.css new file mode 100644 index 000000000..2ef4914e3 --- /dev/null +++ b/static/css/directives/ui/triggered-build-description.css @@ -0,0 +1,44 @@ +.triggered-build-description-element .commit-message { + font-size: 16px; + display: block; +} + +.triggered-build-description-element .commit-message a { + color: black; +} + +.triggered-build-description-element .commit-who img { + width: 16px; + height: 16px; + margin-left: 2px; + margin-right: 2px; + border-radius: 50%; +} + +.triggered-build-description-element .fa-github { + display: inline-block; + margin-left: 6px; + margin-right: 4px; +} + +.triggered-build-description-element .commit-information { + margin-top: 6px; + font-size: 14px; + color: #999; +} + +.triggered-build-description-element .source-commit-link a { + color: #999; +} + +.triggered-build-description-element .source-ref-link a { + color: #999; +} + +.triggered-build-description-element .commit-information > span { + margin-right: 20px; +} + +.triggered-build-description-element .commit-who:before { + content: "by "; +} \ No newline at end of file diff --git a/static/css/pages/build-view.css b/static/css/pages/build-view.css new file mode 100644 index 000000000..649f0005a --- /dev/null +++ b/static/css/pages/build-view.css @@ -0,0 +1,51 @@ +.build-view .build-header .repo-breadcrumb { + font-size: 18px; + margin-left: 12px; +} + +.build-view .build-status-header { + padding: 4px; +} + +.build-view .build-status-header .cor-loader-inline { + margin-right: 10px; +} + +.build-view .build-logs-view { + margin-top: 10px; +} + +.build-view .build-status-header { + font-size: 17px; + margin-top: 10px; +} + +.build-view .build-status-header .fa { + margin-right: 6px; +} + +.build-view .build-icon-message.error { + color: red; +} + +.build-view .build-icon-message.internalerror { + color: #DFFF00; +} + +.build-view .build-icon-message.complete { + color: #2fcc66; +} + +.build-view .build-status-header .cor-options-menu { + float: right; + margin-left: 20px; +} + +.build-view .build-status-header .timing { + float: right; +} + +.build-view .build-status-header .timing .fa { + margin-left: 10px; + margin-right: 4px; +} diff --git a/static/css/quay.css b/static/css/quay.css index bc6e172e9..3062f95e2 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -912,7 +912,7 @@ i.toggle-icon:hover { } .phase-icon.complete { - background-color: rgba(66, 139, 202, 1); + background-color: #2fcc66; } .build-status { diff --git a/static/directives/build-info-bar.html b/static/directives/build-info-bar.html new file mode 100644 index 000000000..4a970d727 --- /dev/null +++ b/static/directives/build-info-bar.html @@ -0,0 +1,18 @@ +
+ + + + +
+ +
{{ build.id }}
+ + +
+ +
+
+ +
+
Manually Started Build
+
\ No newline at end of file diff --git a/static/directives/build-logs-view.html b/static/directives/build-logs-view.html new file mode 100644 index 000000000..285a8b141 --- /dev/null +++ b/static/directives/build-logs-view.html @@ -0,0 +1,37 @@ +
+ + + + + (Waiting for build to start) + +
+
+ +
+ +
+
+ +
+
+ +
+
+ + +
+
+ + + + +
+
+
+
\ No newline at end of file diff --git a/static/directives/logs-view.html b/static/directives/logs-view.html index 28fc8edb0..651f91b3f 100644 --- a/static/directives/logs-view.html +++ b/static/directives/logs-view.html @@ -1,5 +1,4 @@
-
Usage Logs diff --git a/static/directives/resource-view.html b/static/directives/resource-view.html index 0952bf2f4..30e23559b 100644 --- a/static/directives/resource-view.html +++ b/static/directives/resource-view.html @@ -1,9 +1,9 @@
-
-
+
+
{{ errorMessage }}
-
+
diff --git a/static/directives/source-commit-link.html b/static/directives/source-commit-link.html new file mode 100644 index 000000000..d8803b319 --- /dev/null +++ b/static/directives/source-commit-link.html @@ -0,0 +1,4 @@ + + + {{ commitSha.substring(0, 8) }} + \ No newline at end of file diff --git a/static/directives/source-ref-link.html b/static/directives/source-ref-link.html new file mode 100644 index 000000000..dcf142001 --- /dev/null +++ b/static/directives/source-ref-link.html @@ -0,0 +1,15 @@ + + + + + + {{ getTitle(ref) }} + + + + + + {{ getTitle(ref) }} + + + \ No newline at end of file diff --git a/static/directives/triggered-build-description.html b/static/directives/triggered-build-description.html new file mode 100644 index 000000000..1aa33820f --- /dev/null +++ b/static/directives/triggered-build-description.html @@ -0,0 +1,56 @@ +
+ + + + + + + + + + + + Triggered by commit + + + + + + Triggered by commit to + + + {{ build.trigger.config.build_source }} + + + + + + + +
\ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index 12adc7521..bc721d769 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -96,6 +96,9 @@ quayApp.config(['$routeProvider', '$locationProvider', 'pages', function($routeP // Repo Builds .route('/repository/:namespace/:name/build', 'repo-build') + // Repo Build View + .route('/repository/:namespace/:name/build/:buildid', 'build-view') + // Repo Build Package .route('/repository/:namespace/:name/build/:buildid/buildpack', 'build-package') @@ -203,8 +206,8 @@ if (window.__config && window.__config.SENTRY_PUBLIC_DSN) { } // Run the application. -quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanService', '$http', '$timeout', 'CookieService', 'Features', '$anchorScroll', 'UtilService', - function($location, $rootScope, Restangular, UserService, PlanService, $http, $timeout, CookieService, Features, $anchorScroll, UtilService) { +quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanService', '$http', '$timeout', 'CookieService', 'Features', '$anchorScroll', 'UtilService', 'MetaService', + function($location, $rootScope, Restangular, UserService, PlanService, $http, $timeout, CookieService, Features, $anchorScroll, UtilService, MetaService) { var title = window.__config['REGISTRY_TITLE'] || 'Quay.io'; @@ -296,34 +299,39 @@ quayApp.run(['$location', '$rootScope', 'Restangular', 'UserService', 'PlanServi }); $rootScope.$on('$routeChangeSuccess', function (event, current, previous) { - $rootScope.pageClass = ''; $rootScope.current = current.$$route; + $rootScope.currentPage = current; + + $rootScope.pageClass = ''; if (!current.$$route) { return; } - if (current.$$route.title) { - $rootScope.title = current.$$route.title; - } else { - $rootScope.title = title; - } - - if (current.$$route.pageClass) { - $rootScope.pageClass = current.$$route.pageClass; - } - + $rootScope.pageClass = current.$$route.pageClass || ''; $rootScope.newLayout = !!current.$$route.newLayout; - - if (current.$$route.description) { - $rootScope.description = current.$$route.description; - } else { - $rootScope.description = ''; - } - $rootScope.fixFooter = !!current.$$route.fixFooter; + + MetaService.getInitialTitle(current, function(title) { + $rootScope.title = title; + }); + + MetaService.getInitialDescription(current, function(description) { + $rootScope.description = description + }); + $anchorScroll(); }); - $rootScope.$on('$viewContentLoaded', function(event, current) { + $rootScope.$on('$viewContentLoaded', function(event) { + var current = $rootScope.currentPage; + + MetaService.getTitle(current, function(title) { + $rootScope.title = title; + }); + + MetaService.getDescription(current, function(description) { + $rootScope.description = description; + }); + var activeTab = $location.search()['tab']; // Setup deep linking of tabs. This will change the search field of the URL whenever a tab diff --git a/static/js/directives/ui/build-info-bar.js b/static/js/directives/ui/build-info-bar.js new file mode 100644 index 000000000..32850ef6f --- /dev/null +++ b/static/js/directives/ui/build-info-bar.js @@ -0,0 +1,19 @@ +/** + * An element which displays the status of a build in a nice compact bar. + */ +angular.module('quay').directive('buildInfoBar', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/build-info-bar.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'build': '=build', + 'showTime': '=showTime' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/build-logs-view.js b/static/js/directives/ui/build-logs-view.js new file mode 100644 index 000000000..1eeb2fbd4 --- /dev/null +++ b/static/js/directives/ui/build-logs-view.js @@ -0,0 +1,168 @@ +/** + * An element which displays and auto-updates the logs from a build. + */ +angular.module('quay').directive('buildLogsView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/build-logs-view.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'build': '=build', + 'useTimestamps': '=useTimestamps', + 'buildUpdated': '&buildUpdated' + }, + controller: function($scope, $element, $interval, $sanitize, ansi2html, AngularViewArray, + AngularPollChannel, ApiService, Restangular) { + + var result = $element.find('#copyButton').clipboardCopy(); + if (!result) { + $element.find('#copyButton').hide(); + } + + $scope.logEntries = null; + $scope.currentParentEntry = null; + $scope.logStartIndex = 0; + $scope.buildLogsText = ''; + + var pollChannel = null; + var currentBuild = null; + + var appendToTextLog = function(type, message) { + if (type == 'phase') { + text = 'Starting phase: ' + message + '\n'; + } else { + text = message + '\n'; + } + + $scope.buildLogsText += text.replace(new RegExp("\\033\\[[^m]+m"), ''); + }; + + var processLogs = function(logs, startIndex, endIndex) { + if (!$scope.logEntries) { $scope.logEntries = []; } + + // If the start index given is less than that requested, then we've received a larger + // pool of logs, and we need to only consider the new ones. + if (startIndex < $scope.logStartIndex) { + logs = logs.slice($scope.logStartIndex - startIndex); + } + + for (var i = 0; i < logs.length; ++i) { + var entry = logs[i]; + var type = entry['type'] || 'entry'; + if (type == 'command' || type == 'phase' || type == 'error') { + entry['logs'] = AngularViewArray.create(); + entry['index'] = $scope.logStartIndex + i; + + $scope.logEntries.push(entry); + $scope.currentParentEntry = entry; + } else if ($scope.currentParentEntry) { + $scope.currentParentEntry['logs'].push(entry); + } + + appendToTextLog(type, entry['message']); + } + + return endIndex; + }; + + var getBuildStatusAndLogs = function(build, callback) { + var params = { + 'repository': build.repository.namespace + '/' + build.repository.name, + 'build_uuid': build.id + }; + + ApiService.getRepoBuildStatus(null, params, true).then(function(resp) { + if (resp.id != $scope.build.id) { callback(false); return; } + + // Call the build updated handler. + $scope.buildUpdated({'build': resp}); + + // Save the current build. + currentBuild = resp; + + // Load the updated logs for the build. + var options = { + 'start': $scope.logStartIndex + }; + + ApiService.getRepoBuildLogsAsResource(params, true).withOptions(options).get(function(resp) { + // Process the logs we've received. + $scope.logStartIndex = processLogs(resp['logs'], resp['start'], resp['total']); + + // If the build status is an error, open the last two log entries. + if (currentBuild['phase'] == 'error' && $scope.logEntries.length > 1) { + var openLogEntries = function(entry) { + if (entry.logs) { + entry.logs.setVisible(true); + } + }; + + openLogEntries($scope.logEntries[$scope.logEntries.length - 2]); + openLogEntries($scope.logEntries[$scope.logEntries.length - 1]); + } + + // If the build phase is an error or a complete, then we mark the channel + // as closed. + callback(currentBuild['phase'] != 'error' && currentBuild['phase'] != 'complete'); + }, function() { + callback(false); + }); + }, function() { + callback(false); + }); + }; + + var startWatching = function(build) { + // Create a new channel for polling the build status and logs. + var conductStatusAndLogRequest = function(callback) { + getBuildStatusAndLogs(build, callback); + }; + + pollChannel = AngularPollChannel.create($scope, conductStatusAndLogRequest, 5 * 1000 /* 5s */); + pollChannel.start(); + }; + + var stopWatching = function() { + if (pollChannel) { + pollChannel.stop(); + pollChannel = null; + } + }; + + $scope.$watch('useTimestamps', function() { + if (!$scope.logEntries) { return; } + $scope.logEntries = $scope.logEntries.slice(); + }); + + $scope.$watch('build', function(build) { + if (build) { + startWatching(build); + } else { + stopWatching(); + } + }); + + $scope.hasLogs = function(container) { + return container.logs.hasEntries; + }; + + $scope.formatDatetime = function(datetimeString) { + var dt = new Date(datetimeString); + return dt.toLocaleString(); + } + + $scope.processANSI = function(message, container) { + var filter = container.logs._filter = (container.logs._filter || ansi2html.create()); + + // Note: order is important here. + var setup = filter.getSetupHtml(); + var stream = filter.addInputToStream(message); + var teardown = filter.getTeardownHtml(); + return setup + stream + teardown; + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/copy-box.js b/static/js/directives/ui/copy-box.js index 7c490221c..712b6814d 100644 --- a/static/js/directives/ui/copy-box.js +++ b/static/js/directives/ui/copy-box.js @@ -23,6 +23,9 @@ $.fn.clipboardCopy = function() { ZeroClipboard.on('aftercopy', function(e) { var container = e.target.parentNode.parentNode.parentNode; var message = $(container).find('.clipboard-copied-message')[0]; + if (!message) { + return; + } // Resets the animation. var elem = message; diff --git a/static/js/directives/ui/resource-view.js b/static/js/directives/ui/resource-view.js index a0e71c0e0..c7fc2e4c1 100644 --- a/static/js/directives/ui/resource-view.js +++ b/static/js/directives/ui/resource-view.js @@ -11,9 +11,29 @@ angular.module('quay').directive('resourceView', function () { restrict: 'C', scope: { 'resource': '=resource', + 'resources': '=resources', 'errorMessage': '=errorMessage' }, controller: function($scope, $element) { + $scope.getState = function() { + if (!$scope.resources && !$scope.resource) { + return 'loading'; + } + + var resources = $scope.resources || [$scope.resource]; + for (var i = 0; i < resources.length; ++i) { + var current = resources[i]; + if (current.loading) { + return 'loading'; + } + + if (current.error) { + return 'error'; + } + } + + return 'ready'; + }; } }; return directiveDefinitionObject; diff --git a/static/js/directives/ui/source-commit-link.js b/static/js/directives/ui/source-commit-link.js new file mode 100644 index 000000000..68bb42bd4 --- /dev/null +++ b/static/js/directives/ui/source-commit-link.js @@ -0,0 +1,22 @@ +/** + * An element which displays a link to a commit in Git or Mercurial or another source control. + */ +angular.module('quay').directive('sourceCommitLink', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/source-commit-link.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'commitSha': '=commitSha', + 'urlTemplate': '=urlTemplate' + }, + controller: function($scope, $element) { + $scope.getUrl = function(sha, template) { + return template.replace('{sha}', sha); + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/source-ref-link.js b/static/js/directives/ui/source-ref-link.js new file mode 100644 index 000000000..170ff3f04 --- /dev/null +++ b/static/js/directives/ui/source-ref-link.js @@ -0,0 +1,41 @@ +/** + * An element which displays a link to a branch or tag in source control. + */ +angular.module('quay').directive('sourceRefLink', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/source-ref-link.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'ref': '=ref', + 'branchTemplate': '=branchTemplate', + 'tagTemplate': '=tagTemplate' + }, + controller: function($scope, $element) { + $scope.getKind = function(ref) { + var parts = (ref || '').split('/'); + if (parts.length < 3) { + return ''; + } + + return parts[1]; + }; + + $scope.getTitle = function(ref) { + var parts = (ref || '').split('/'); + if (parts.length < 3) { + return ''; + } + + return parts[2]; + }; + + $scope.getUrl = function(ref, template, kind) { + return template.replace('{' + kind + '}', $scope.getTitle(ref)); + }; + } + }; + return directiveDefinitionObject; +}); diff --git a/static/js/directives/ui/trigger-description.js b/static/js/directives/ui/trigger-description.js index 9b09960bc..0921fd023 100644 --- a/static/js/directives/ui/trigger-description.js +++ b/static/js/directives/ui/trigger-description.js @@ -1,5 +1,5 @@ /** - * An element which displays information about a build trigger. + * DEPRECATED: An element which displays information about a build trigger. */ angular.module('quay').directive('triggerDescription', function () { var directiveDefinitionObject = { diff --git a/static/js/directives/ui/triggered-build-description.js b/static/js/directives/ui/triggered-build-description.js new file mode 100644 index 000000000..63cafa779 --- /dev/null +++ b/static/js/directives/ui/triggered-build-description.js @@ -0,0 +1,21 @@ +/** + * An element which displays information about a build that was triggered from an outside source. + */ +angular.module('quay').directive('triggeredBuildDescription', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/triggered-build-description.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'build': '=build' + }, + controller: function($scope, $element, KeyService, TriggerService) { + $scope.getGitHubRepoURL = function(build) { + return KeyService['githubTriggerEndpoint'] + build.trigger.config.build_source + '/'; + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/pages/build-view.js b/static/js/pages/build-view.js new file mode 100644 index 000000000..27fe4afd7 --- /dev/null +++ b/static/js/pages/build-view.js @@ -0,0 +1,75 @@ +(function() { + /** + * Build view page. Displays the view of a particular build for a repository. + */ + angular.module('quayPages').config(['pages', function(pages) { + pages.create('build-view', 'build-view.html', BuildViewCtrl, { + newLayout: true, + title: 'Build {{ build.display_name }}', + description: 'Logs and status for build {{ build.display_name }}' + }); + }]); + + function BuildViewCtrl($scope, ApiService, $routeParams, AngularPollChannel, CookieService) { + $scope.namespace = $routeParams.namespace; + $scope.name = $routeParams.name; + $scope.build_uuid = $routeParams.buildid; + + $scope.showLogTimestamps = CookieService.get('quay.showBuildLogTimestamps') == 'true'; + + var loadBuild = function() { + var params = { + 'repository': $scope.namespace + '/' + $scope.name, + 'build_uuid': $scope.build_uuid + }; + + $scope.buildResource = ApiService.getRepoBuildAsResource(params).get(function(build) { + $scope.build = build; + $scope.originalBuild = build; + }); + }; + + var loadRepository = function() { + var params = { + 'repository': $scope.namespace + '/' + $scope.name + }; + + $scope.repoResource = ApiService.getRepoAsResource(params).get(function(repo) { + $scope.repo = repo; + }, ApiService.errorDisplay('Cannot load repository')); + }; + + // Page startup: + loadRepository(); + loadBuild(); + + $scope.askCancelBuild = function(build) { + bootbox.confirm('Are you sure you want to cancel this build?', function(r) { + if (r) { + var params = { + 'repository': $scope.namespace + '/' + $scope.name, + 'build_uuid': build.id + }; + + ApiService.cancelRepoBuild(null, params).then(function() { + document.location = '/repository/' + $scope.namespace + '/' + $scope.name; + }, ApiService.errorDisplay('Cannot cancel build')); + } + }); + }; + + $scope.toggleTimestamps = function() { + $scope.showLogTimestamps = !$scope.showLogTimestamps; + CookieService.putPermanent('quay.showBuildLogTimestamps', $scope.showLogTimestamps); + }; + + $scope.setUpdatedBuild = function(build) { + $scope.build = build; + }; + + $scope.isBuilding = function(build) { + if (!build) { return true; } + return build.phase != 'complete' && build.phase != 'error'; + }; + } +})(); \ No newline at end of file diff --git a/static/js/services/meta-service.js b/static/js/services/meta-service.js new file mode 100644 index 000000000..f0b4cb989 --- /dev/null +++ b/static/js/services/meta-service.js @@ -0,0 +1,76 @@ +/** + * Service which helps set the contents of the tags (and the of a page). + */ +angular.module('quay').factory('MetaService', ['$interpolate', 'Config', '$rootScope', '$interval', + function($interpolate, Config, $rootScope, $interval) { + var metaService = {}; + var intervals = []; + + var interpolate = function(page, expr, callback) { + var previous = ''; + + var currentInterval = $interval(function() { + var inter = $interpolate(expr, true, null, true); + var result = inter(page.scope) + + if (result != previous) { + $interval.cancel(currentInterval); + } + + previous = result; + callback(result); + }, 500); + + intervals.push(currentInterval); + }; + + var initial = function(value, default_value, callback) { + for (var i = 0; i < intervals.length; ++i) { + $interval.cancel(intervals[i]); + } + + intervals = []; + + if (!value) { + callback(default_value); + return; + } + + if (value.indexOf('{{') < 0) { + callback(default_value); + return; + } + + callback('Loading...'); + }; + + metaService.getInitialTitle = function(page, callback) { + var route = page.$$route; + initial(route && route.title, Config.REGISTRY_TITLE_SHORT, callback); + }; + + metaService.getInitialDescription = function(page, callback) { + var route = page.$$route; + initial(route && route.description, Config.REGISTRY_TITLE_SHORT, callback); + }; + + metaService.getTitle = function(page, callback) { + var route = page.$$route; + if (!route || !route.title || route.title.indexOf('{{') < 0) { + return; + } + + interpolate(page, route.title, callback); + }; + + metaService.getDescription = function(page, callback) { + var route = page.$$route; + if (!route || !route.description || route.description.indexOf('{{') < 0) { + return; + } + + interpolate(page, route.description, callback); + }; + + return metaService; +}]); diff --git a/static/partials/build-view.html b/static/partials/build-view.html new file mode 100644 index 000000000..1a2f00c77 --- /dev/null +++ b/static/partials/build-view.html @@ -0,0 +1,64 @@ +<div class="build-view"> + <div class="resource-view" resources="[buildResource, repoResource]" + error-message="'Build not found'"> + + <div class="page-content"> + <div class="cor-title"> + <span class="cor-title-link"> + <a class="back-link" href="/repository/{{ namespace }}/{{ name }}?tab=builds"> + <i class="fa fa-hdd-o" style="margin-right: 4px"></i> + {{ namespace }}/{{ name }} + </a> + </span> + <span class="cor-title-content"> + <i class="fa fa-tasks fa-lg" style="margin-right: 10px"></i> {{ build.display_name }} + </span> + </div> + + <div class="co-main-content-panel"> + <!-- Build Information --> + <div class="build-info-bar" build="build" show-time="false"></div> + + <!-- Current Status --> + <div class="build-status-header"> + <span class="build-icon-message" ng-class="build.phase"> + <span class="cor-loader-inline" ng-if="isBuilding(build)"></span> + <span ng-if="!isBuilding(build)"> + <i class="fa fa-check-circle" ng-if="build.phase == 'complete'"></i> + <i class="fa fa-times-circle" ng-if="build.phase == 'error'"></i> + <i class="fa fa-exclamation-circle" ng-if="build.phase == 'internalerror'"></i> + </span> + <span class="build-message" phase="build.phase"></span> + </span> + + <span class="cor-options-menu"> + <span class="cor-option" option-click="toggleTimestamps()"> + <span ng-if="showLogTimestamps"> + <i class="fa fa-clock-o"></i> Hide Timestamps + </span> + <span ng-if="!showLogTimestamps"> + <i class="fa fa-clock-o"></i> Show Timestamps + </span> + </span> + <span class="cor-option" option-click="askCancelBuild(build)" + ng-if="build.phase == 'waiting'"> + <i class="fa fa-times"></i> Cancel Build + </span> + </span> + + <div class="timing"> + <i class="fa fa-clock-o"></i> + Build started + <span am-time-ago="build.started || 0"></span> + </div> + </div> + + <!-- Build Logs --> + <div class="build-logs-view" + build="originalBuild" + use-timestamps="showLogTimestamps" + build-updated="setUpdatedBuild(build)"></div> + </div> + </div> + </div> +</div> \ No newline at end of file diff --git a/test/testlogs.py b/test/testlogs.py index 49dcf3c7c..cf140f085 100644 --- a/test/testlogs.py +++ b/test/testlogs.py @@ -1,4 +1,5 @@ import logging +import datetime from random import SystemRandom from functools import wraps, partial @@ -108,7 +109,13 @@ class TestBuildLogs(RedisBuildLogs): return script def _generate_phase(self, start_weight, phase_name): - return (start_weight, {'message': phase_name, 'type': self.PHASE}, + message = { + 'message': phase_name, + 'type': self.PHASE, + 'datetime': str(datetime.datetime.now()) + } + + return (start_weight, message, (phase_name, deepcopy(self.STATUS_TEMPLATE))) def _generate_command(self, command_num, total_commands, command_weight): @@ -123,6 +130,7 @@ class TestBuildLogs(RedisBuildLogs): msg = { 'message': 'Step %s: %s %s' % (command_num, command, sentence), 'type': self.COMMAND, + 'datetime': str(datetime.datetime.now()) } status = deepcopy(self.STATUS_TEMPLATE) status['total_commands'] = total_commands @@ -133,10 +141,26 @@ class TestBuildLogs(RedisBuildLogs): def _generate_logs(count): others = [] if random.randint(0, 10) <= 8: - count = count - 2 - others = [(1, {'message': '\x1b[91m' + get_sentence()}, None), (1, {'message': '\x1b[0m'}, None)] + premessage = { + 'message': '\x1b[91m' + get_sentence(), + 'datetime': str(datetime.datetime.now()) + } - return others + [(1, {'message': get_sentence()}, None) for _ in range(count)] + postmessage = { + 'message': '\x1b[0m', + 'datetime': str(datetime.datetime.now()) + } + + count = count - 2 + others = [(1, premessage, None), (1, postmessage, None)] + + def get_message(): + return { + 'message': get_sentence(), + 'datetime': str(datetime.datetime.now()) + } + + return others + [(1, get_message(), None) for _ in range(count)] @staticmethod def _compute_total_completion(statuses, total_images):