diff --git a/static/css/quay.css b/static/css/quay.css index 56a04bbc5..2773efc0a 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -1742,10 +1742,96 @@ p.editable:hover i { background: #222; color: white; padding: 10px; - font-family: Consolas, "Lucida Console", Monaco, monospace; overflow: auto; } + +.repo-build .build-pane .build-logs .command-logs { + margin: 10px; + padding-right: 10px; +} + +.repo-build .build-pane .build-logs .command-entry, +.repo-build .build-pane .build-logs .log-entry { + font-family: Consolas, "Lucida Console", Monaco, monospace; +} + +.repo-build .build-pane .build-logs .command-entry { + cursor: pointer; + position: relative; +} + +.repo-build .build-pane .build-logs .command-entry i.fa.chevron { + color: #666; + margin-right: 4px; + width: 14px; + text-align: center; + + position: absolute; + top: 2px; + left: 0px; +} + +.repo-build .build-pane .build-logs .command-entry .label { + text-align: center; + margin-right: 4px; + vertical-align: middle; + width: 86px; + display: inline-block; + background-color: #aaa; + + position: absolute; + top: 2px; + left: 24px; +} + +.repo-build .build-pane .build-logs .command-entry .command-title { + display: block; + padding-left: 120px; +} + +.label.FROM { + background-color: #5bc0de !important; +} + +.label.CMD, .label.EXPOSE, .label.ENTRYPOINT { + background-color: #428bca !important; +} + +.label.RUN, .label.ADD { + background-color: #5cb85c !important; +} + +.label.ENV, .label.VOLUME, .label.USER, .label.WORKDIR { + background-color: #f0ad4e !important; +} + +.label.MAINTAINER { + background-color: #aaa !important; +} + +.repo-build .build-pane .build-logs .log-entry { + position: relative; +} + +.repo-build .build-pane .build-logs .log-entry .message { + display: inline-block; + margin-left: 46px; +} + +.repo-build .build-pane .build-logs .log-entry .id { + color: #aaa; + padding-right: 6px; + margin-right: 6px; + text-align: right; + font-size: 12px; + width: 40px; + + position: absolute; + top: 4px; + left: 4px; +} + .repo-admin .right-info { font-size: 11px; margin-top: 10px; diff --git a/static/js/app.js b/static/js/app.js index b27997bc3..69ee8e886 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -103,7 +103,7 @@ function getMarkedDown(string) { } // Start the application code itself. -quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'restangular', 'angularMoment', 'angulartics', /*'angulartics.google.analytics',*/ 'angulartics.mixpanel', '$strap.directives', 'ngCookies', 'ngSanitize', 'angular-md5'], function($provide, cfpLoadingBarProvider) { +quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'restangular', 'angularMoment', 'angulartics', /*'angulartics.google.analytics',*/ 'angulartics.mixpanel', '$strap.directives', 'ngCookies', 'ngSanitize', 'angular-md5', 'pasvaz.bindonce'], function($provide, cfpLoadingBarProvider) { cfpLoadingBarProvider.includeSpinner = false; $provide.factory('UtilService', ['$sanitize', function($sanitize) { @@ -151,7 +151,7 @@ quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'rest $provide.factory('ApiService', ['Restangular', function(Restangular) { var apiService = {}; - var getResource = function(path) { + var getResource = function(path, opt_background) { var resource = {}; resource.url = path; resource.withOptions = function(options) { @@ -169,6 +169,12 @@ quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'rest 'hasError': false }; + if (opt_background) { + performer.withHttpConfig({ + 'ignoreLoadingBar': true + }); + } + performer.get(options).then(function(resp) { result.value = processor(resp); result.loading = false; @@ -240,27 +246,33 @@ quayApp = angular.module('quay', ['ngRoute', 'chieffancypants.loadingBar', 'rest var buildMethodsForEndpoint = function(endpoint) { var method = endpoint.methods[0].toLowerCase(); var methodName = formatMethodName(endpoint['name']); - apiService[methodName] = function(opt_options, opt_parameters) { - return Restangular.one(buildUrl(endpoint['path'], opt_parameters))['custom' + method.toUpperCase()](opt_options); + apiService[methodName] = function(opt_options, opt_parameters, opt_background) { + var one = Restangular.one(buildUrl(endpoint['path'], opt_parameters)); + if (opt_background) { + one.withHttpConfig({ + 'ignoreLoadingBar': true + }); + } + return one['custom' + method.toUpperCase()](opt_options); }; if (method == 'get') { - apiService[methodName + 'AsResource'] = function(opt_parameters) { - return getResource(buildUrl(endpoint['path'], opt_parameters)); + apiService[methodName + 'AsResource'] = function(opt_parameters, opt_background) { + return getResource(buildUrl(endpoint['path'], opt_parameters), opt_background); }; } if (endpoint['user_method']) { - apiService[getGenericMethodName(endpoint['user_method'])] = function(orgname, opt_options, opt_parameters) { + apiService[getGenericMethodName(endpoint['user_method'])] = function(orgname, opt_options, opt_parameters, opt_background) { if (orgname) { if (orgname.name) { orgname = orgname.name; } - var params = jQuery.extend({'orgname' : orgname}, opt_parameters || {}); + var params = jQuery.extend({'orgname' : orgname}, opt_parameters || {}, opt_background); return apiService[methodName](opt_options, params); } else { - return apiService[formatMethodName(endpoint['user_method'])](opt_options, opt_parameters); + return apiService[formatMethodName(endpoint['user_method'])](opt_options, opt_parameters, opt_background); } }; } diff --git a/static/js/controllers.js b/static/js/controllers.js index 5644b1ba9..654490026 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -529,13 +529,11 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi }; var getBuildInfo = function(repo) { - // Note: We use restangular manually here because we need to turn off the loading bar. - var buildInfo = Restangular.one('repository/' + repo.namespace + '/' + repo.name + '/build/'); - buildInfo.withHttpConfig({ - 'ignoreLoadingBar': true - }); + var params = { + 'repository': repo.namespace + '/' + repo.name + }; - buildInfo.get().then(function(resp) { + ApiService.getRepoBuilds(null, params, true).then(function(resp) { var runningBuilds = []; for (var i = 0; i < resp.builds.length; ++i) { var build = resp.builds[i]; @@ -621,11 +619,41 @@ function RepoCtrl($scope, $sanitize, Restangular, ImageMetadataService, ApiServi loadViewInfo(); } -function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $location, $interval) { +function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $location, $interval, $sanitize) { var namespace = $routeParams.namespace; var name = $routeParams.name; var pollTimerHandle = null; + var registryHandlers = { + 'quay.io': function(pieces) { + var rnamespace = pieces[pieces.length - 2]; + var rname = pieces[pieces.length - 1]; + return '/repository/' + rnamespace + '/' + rname + '/'; + }, + + '': function(pieces) { + var rnamespace = pieces.length == 1 ? '_' : pieces[0]; + var rname = pieces[pieces.length - 1]; + return 'https://index.docker.io/' + rnamespace + '/' + rname + '/'; + } + }; + + var kindHandlers = { + 'FROM': function(title) { + var pieces = title.split('/'); + var registry = pieces.length < 2 ? '' : pieces[0]; + if (!registryHandlers[registry]) { + return title; + } + + return ' ' + title + ''; + } + }; + + $scope.$on('$destroy', function() { + stopPollTimer(); + }); + // Watch for changes to the current parameter. $scope.$on('$routeUpdate', function(){ if ($location.search().current) { @@ -645,6 +673,44 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope return id.substr(lastIndex + 1); }; + $scope.getCommandKind = function(fullTitle) { + var colon = fullTitle.indexOf(':'); + var title = getTitleWithoutStep(fullTitle); + if (!title) { + return null; + } + + var space = title.indexOf(' '); + return title.substring(0, space); + }; + + $scope.getCommandTitleHtml = function(fullTitle) { + var title = getTitleWithoutStep(fullTitle) || fullTitle; + var space = title.indexOf(' '); + if (space <= 0) { + return $sanitize(title); + } + + var kind = $scope.getCommandKind(fullTitle); + var sanitized = $sanitize(title.substring(space + 1)); + + var handler = kindHandlers[kind || '']; + if (handler) { + return handler(sanitized); + } else { + return sanitized; + } + }; + + $scope.toggleCommand = function(command) { + command.expanded = !command.expanded; + + if (command.expanded && !command.logs) { + // Load the logs for the command. + loadCommandLogs(command); + } + }; + $scope.setCurrentBuild = function(buildId, opt_updateURL) { // Find the build. for (var i = 0; i < $scope.builds.length; ++i) { @@ -656,9 +722,16 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope }; $scope.setCurrentBuildInternal = function(build, opt_updateURL) { + if (build == $scope.currentBuild) { return; } + stopPollTimer(); + $scope.commands = null; + $scope.commandMap = {}; + $scope.logs = null; + $scope.logStartIndex = 0; $scope.currentBuild = build; + if (opt_updateURL) { if (build) { $location.search('current', build.id); @@ -677,6 +750,15 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope checkPollTimer(); }; + var getTitleWithoutStep = function(fullTitle) { + var colon = fullTitle.indexOf(':'); + if (colon <= 0) { + return null; + } + + return $.trim(fullTitle.substring(colon + 1)); + } + var checkPollTimer = function() { var build = $scope.currentBuild; if (!build) { @@ -686,8 +768,10 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope if (build['phase'] != 'complete' && build['phase'] != 'error') { startPollTimer(); + return true; } else { stopPollTimer(); + return false; } }; @@ -697,25 +781,99 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope var startPollTimer = function() { stopPollTimer(); - pollTimerHandle = $interval(getBuildStatus, 1000); + pollTimerHandle = $interval(getBuildStatusAndLogs, 2000); }; - var getBuildStatus = function() { - if (!$scope.currentBuild) { return; } + var processLogs = function(logs, startIndex) { + var currentCommand = $scope.commands.length > 0 ? $scope.commands[$scope.commands.length - 1] : null; + for (var i = 0; i < logs.length; ++i) { + var entry = logs[i]; + if (entry['is_command']) { + var existing = $scope.commandMap[entry['message']]; + if (existing) { + currentCommand = existing; + continue; + } - // Note: We use restangular manually here because we need to turn off the loading bar. - var buildStatus = Restangular.one('repository/' + namespace + '/' + name + '/build/' + $scope.currentBuild.id + '/status'); - buildStatus.withHttpConfig({ - 'ignoreLoadingBar': true + currentCommand = { + 'message': entry['message'], + 'index': startIndex + i + }; + $scope.commands.push(currentCommand); + $scope.commandMap[entry['message']] = currentCommand; + continue; + } + if (!currentCommand.logs) { + currentCommand.logs = []; + } + currentCommand.logs.push(entry); + } + + if (currentCommand.expanded == null) { + currentCommand.expanded = true; + } + }; + + var loadCommandLogs = function(command) { + var start = command['index'] + 1; + var end = null; + + var currentCommandIndex = jQuery.inArray(command, $scope.commands); + if (currentCommandIndex >= 0 && currentCommandIndex < $scope.commands.length - 1) { + var nextCommand = $scope.commands[currentCommandIndex + 1]; + end = nextCommand.index ? nextCommand.index - 1 : null; + } + + var params = { + 'repository': namespace + '/' + name, + 'build_uuid': $scope.currentBuild.id + }; + + var options = { + 'start': start + }; + + if (end != null) { + options['end'] = end; + } + + ApiService.getRepoBuildLogsAsResource(params, true).withOptions(options).get(function(resp) { + if (resp['logs']) { + command.logs = resp['logs']; + } }); + }; + + var getBuildStatusAndLogs = function() { + if (!$scope.currentBuild || $scope.polling) { return; } $scope.polling = true; - buildStatus.get().then(function(resp) { + + var params = { + 'repository': namespace + '/' + name, + 'build_uuid': $scope.currentBuild.id + }; + + ApiService.getRepoBuildStatus(null, params, true).then(function(resp) { // Note: We use extend here rather than replacing as Angular is depending on the // root build object to remain the same object. $.extend(true, $scope.currentBuild, resp); - $scope.polling = false; checkPollTimer(); + + // Load the updated logs for the build. + var options = { + 'commands': $scope.commands == null, + 'start': $scope.logStartIndex + }; + ApiService.getRepoBuildLogsAsResource(params, true).withOptions(options).get(function(resp) { + if (resp['commands']) { + $scope.commands = resp['commands']; + } + + processLogs(resp.logs, $scope.logStartIndex); + $scope.logStartIndex = resp['total']; + $scope.polling = false; + }); }); }; @@ -725,18 +883,15 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope $scope.repository = ApiService.getRepoAsResource(params).get(function(repo) { $rootScope.title = 'Repository Builds'; $scope.repo = repo; - getBuildInfo(); }); }; var getBuildInfo = function(repo) { - // Note: We use restangular manually here because we need to turn off the loading bar. - var buildInfo = Restangular.one('repository/' + namespace + '/' + name + '/build/'); - buildInfo.withHttpConfig({ - 'ignoreLoadingBar': true - }); + var params = { + 'repository': namespace + '/' + name + }; - buildInfo.get().then(function(resp) { + ApiService.getRepoBuilds(null, params).then(function(resp) { $scope.builds = resp.builds; if ($location.search().current) { @@ -748,6 +903,7 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope }; fetchRepository(); + getBuildInfo(); } function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope) { diff --git a/static/lib/bindonce.min.js b/static/lib/bindonce.min.js new file mode 100644 index 000000000..2c26c0cf0 --- /dev/null +++ b/static/lib/bindonce.min.js @@ -0,0 +1 @@ +(function(){"use strict";var bindonceModule=angular.module("pasvaz.bindonce",[]);bindonceModule.directive("bindonce",function(){var toBoolean=function(value){if(value&&value.length!==0){var v=angular.lowercase(""+value);value=!(v==="f"||v==="0"||v==="false"||v==="no"||v==="n"||v==="[]")}else{value=false}return value};var msie=parseInt((/msie (\d+)/.exec(angular.lowercase(navigator.userAgent))||[])[1],10);if(isNaN(msie)){msie=parseInt((/trident\/.*; rv:(\d+)/.exec(angular.lowercase(navigator.userAgent))||[])[1],10)}var bindonceDirective={restrict:"AM",controller:["$scope","$element","$attrs","$interpolate",function($scope,$element,$attrs,$interpolate){var showHideBinder=function(elm,attr,value){var show=attr==="show"?"":"none";var hide=attr==="hide"?"":"none";elm.css("display",toBoolean(value)?show:hide)};var classBinder=function(elm,value){if(angular.isObject(value)&&!angular.isArray(value)){var results=[];angular.forEach(value,function(value,index){if(value)results.push(index)});value=results}if(value){elm.addClass(angular.isArray(value)?value.join(" "):value)}};var ctrl={watcherRemover:undefined,binders:[],group:$attrs.boName,element:$element,ran:false,addBinder:function(binder){this.binders.push(binder);if(this.ran){this.runBinders()}},setupWatcher:function(bindonceValue){var that=this;this.watcherRemover=$scope.$watch(bindonceValue,function(newValue){if(newValue===undefined)return;that.removeWatcher();that.runBinders()},true)},removeWatcher:function(){if(this.watcherRemover!==undefined){this.watcherRemover();this.watcherRemover=undefined}},runBinders:function(){while(this.binders.length>0){var binder=this.binders.shift();if(this.group&&this.group!=binder.group)continue;var value=binder.scope.$eval(binder.interpolate?$interpolate(binder.value):binder.value);switch(binder.attr){case"boIf":if(toBoolean(value)){binder.transclude(binder.scope.$new(),function(clone){var parent=binder.element.parent();var afterNode=binder.element&&binder.element[binder.element.length-1];var parentNode=parent&&parent[0]||afterNode&&afterNode.parentNode;var afterNextSibling=afterNode&&afterNode.nextSibling||null;angular.forEach(clone,function(node){parentNode.insertBefore(node,afterNextSibling)})})}break;case"boSwitch":var selectedTranscludes,switchCtrl=binder.controller[0];if(selectedTranscludes=switchCtrl.cases["!"+value]||switchCtrl.cases["?"]){binder.scope.$eval(binder.attrs.change);angular.forEach(selectedTranscludes,function(selectedTransclude){selectedTransclude.transclude(binder.scope.$new(),function(clone){var parent=selectedTransclude.element.parent();var afterNode=selectedTransclude.element&&selectedTransclude.element[selectedTransclude.element.length-1];var parentNode=parent&&parent[0]||afterNode&&afterNode.parentNode;var afterNextSibling=afterNode&&afterNode.nextSibling||null;angular.forEach(clone,function(node){parentNode.insertBefore(node,afterNextSibling)})})})}break;case"boSwitchWhen":var ctrl=binder.controller[0];ctrl.cases["!"+binder.attrs.boSwitchWhen]=ctrl.cases["!"+binder.attrs.boSwitchWhen]||[];ctrl.cases["!"+binder.attrs.boSwitchWhen].push({transclude:binder.transclude,element:binder.element});break;case"boSwitchDefault":var ctrl=binder.controller[0];ctrl.cases["?"]=ctrl.cases["?"]||[];ctrl.cases["?"].push({transclude:binder.transclude,element:binder.element});break;case"hide":case"show":showHideBinder(binder.element,binder.attr,value);break;case"class":classBinder(binder.element,value);break;case"text":binder.element.text(value);break;case"html":binder.element.html(value);break;case"style":binder.element.css(value);break;case"src":binder.element.attr(binder.attr,value);if(msie)binder.element.prop("src",value);break;case"attr":angular.forEach(binder.attrs,function(attrValue,attrKey){var newAttr,newValue;if(attrKey.match(/^boAttr./)&&binder.attrs[attrKey]){newAttr=attrKey.replace(/^boAttr/,"").replace(/([a-z])([A-Z])/g,"$1-$2").toLowerCase();newValue=binder.scope.$eval(binder.attrs[attrKey]);binder.element.attr(newAttr,newValue)}});break;case"href":case"alt":case"title":case"id":case"value":binder.element.attr(binder.attr,value);break}}this.ran=true}};return ctrl}],link:function(scope,elm,attrs,bindonceController){var value=attrs.bindonce?scope.$eval(attrs.bindonce):true;if(value!==undefined){bindonceController.runBinders()}else{bindonceController.setupWatcher(attrs.bindonce);elm.bind("$destroy",bindonceController.removeWatcher)}}};return bindonceDirective});angular.forEach([{directiveName:"boShow",attribute:"show"},{directiveName:"boHide",attribute:"hide"},{directiveName:"boClass",attribute:"class"},{directiveName:"boText",attribute:"text"},{directiveName:"boHtml",attribute:"html"},{directiveName:"boSrcI",attribute:"src",interpolate:true},{directiveName:"boSrc",attribute:"src"},{directiveName:"boHrefI",attribute:"href",interpolate:true},{directiveName:"boHref",attribute:"href"},{directiveName:"boAlt",attribute:"alt"},{directiveName:"boTitle",attribute:"title"},{directiveName:"boId",attribute:"id"},{directiveName:"boStyle",attribute:"style"},{directiveName:"boValue",attribute:"value"},{directiveName:"boAttr",attribute:"attr"},{directiveName:"boIf",transclude:"element",terminal:true,priority:1e3},{directiveName:"boSwitch",require:"boSwitch",controller:function(){this.cases={}}},{directiveName:"boSwitchWhen",transclude:"element",priority:800,require:"^boSwitch"},{directiveName:"boSwitchDefault",transclude:"element",priority:800,require:"^boSwitch"}],function(boDirective){var childPriority=200;return bindonceModule.directive(boDirective.directiveName,function(){var bindonceDirective={priority:boDirective.priority||childPriority,transclude:boDirective.transclude||false,terminal:boDirective.terminal||false,require:["^bindonce"].concat(boDirective.require||[]),controller:boDirective.controller,compile:function(tElement,tAttrs,transclude){return function(scope,elm,attrs,controllers){var bindonceController=controllers[0];var name=attrs.boParent;if(name&&bindonceController.group!==name){var element=bindonceController.element.parent();bindonceController=undefined;var parentValue;while(element[0].nodeType!==9&&element.length){if((parentValue=element.data("$bindonceController"))&&parentValue.group===name){bindonceController=parentValue;break}element=element.parent()}if(!bindonceController){throw new Error("No bindonce controller: "+name)}}bindonceController.addBinder({element:elm,attr:boDirective.attribute||boDirective.directiveName,attrs:attrs,value:attrs[boDirective.directiveName],interpolate:boDirective.interpolate,group:name,transclude:transclude,controller:controllers.slice(1),scope:scope})}}};return bindonceDirective})})})(); \ No newline at end of file diff --git a/static/partials/repo-build.html b/static/partials/repo-build.html index 6913f4035..3f4759b42 100644 --- a/static/partials/repo-build.html +++ b/static/partials/repo-build.html @@ -40,7 +40,29 @@
- some logs here +
+ +
+
+
+ + + + + + +
+
+
+ + +
+
+ +
+
+
diff --git a/templates/base.html b/templates/base.html index 731e19d18..a0ff44a21 100644 --- a/templates/base.html +++ b/templates/base.html @@ -53,6 +53,7 @@ + diff --git a/test/testlogs.py b/test/testlogs.py index 1df202e68..7ba240840 100644 --- a/test/testlogs.py +++ b/test/testlogs.py @@ -5,6 +5,7 @@ from random import SystemRandom from loremipsum import get_sentence from data.buildlogs import BuildLogs +from random import choice logger = logging.getLogger(__name__) @@ -24,10 +25,22 @@ class TestBuildLogs(BuildLogs): self.request_counter = 0 self._generate_logs() + def _get_random_command(self): + COMMANDS = ['FROM', 'MAINTAINER', 'RUN', 'CMD', 'EXPOSE', 'ENV', 'ADD', + 'ENTRYPOINT', 'VOLUME', 'USER', 'WORKDIR'] + + return choice(COMMANDS) + def _generate_command(self): self.last_command += 1 + + sentence = get_sentence() + command = self._get_random_command() + if command == 'FROM': + sentence = choice(['ubuntu', 'quay.io/devtable/simple', 'quay.io/buynlarge/orgrepo', 'stackbrew/ubuntu:precise']) + return { - 'message': 'Step %s : %s' % (self.last_command, get_sentence()), + 'message': 'Step %s: %s %s' % (self.last_command, command, sentence), 'is_command': True, }