From 6fb3d2e9a77d7d14cfcbf92b54c462037e2d883e Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Wed, 29 Jun 2016 14:06:42 -0700 Subject: [PATCH 01/11] dynamically load job reference step info for display in node status --- .../assets/javascripts/executionStateKO.js | 418 +++++++++++++++++- .../grails-app/assets/javascripts/workflow.js | 114 ++--- .../assets/javascripts/workflow.test.js | 44 +- .../ScheduledExecutionController.groovy | 75 ++++ rundeckapp/grails-app/views/common/_js.gsp | 1 + .../execution/_nodeCurrentStateSimpleKO.gsp | 4 +- .../execution/_wfstateNodeModelDisplay.gsp | 8 +- .../grails-app/views/execution/show.gsp | 17 +- 8 files changed, 586 insertions(+), 95 deletions(-) diff --git a/rundeckapp/grails-app/assets/javascripts/executionStateKO.js b/rundeckapp/grails-app/assets/javascripts/executionStateKO.js index 0033c5c754a..eb71602feac 100644 --- a/rundeckapp/grails-app/assets/javascripts/executionStateKO.js +++ b/rundeckapp/grails-app/assets/javascripts/executionStateKO.js @@ -17,6 +17,411 @@ limitations under the License. */ +/** + * Info about an indexed step within a particular job + * @param parent + * @param ndx 0 based index + * @param data + * @constructor + */ +function JobStepInfo(parent,ndx,data){ + "use strict"; + var self = this; + /** + * Owner is a JobWorkflow + */ + self.parent = parent; + self.ndx = ndx; + self.jobId = ko.observable(data.id); + self.ehJobId = ko.observable(data.ehId); + self.type = ko.observable(data.type||'.'); + self.stepident = ko.observable(data.stepident||'(Unknown)'); +} + +/** + * A job containing a sequence of steps + * @param multi multiworkflow object + * @param workflow array of JobStepInfo + * @param id job id + * @constructor + */ +function JobWorkflow(multi,workflow,id){ + "use strict"; + var self=this; + self.id=id; + self.multi=multi; + self.workflow=workflow||[]; + + /** + * Insert a step + * @param ndx index + * @param data step + */ + self.insert=function(ndx,data){ + self.workflow[ndx]=data; + }; +} +/** + * A step in an execution identified by a stepctx string, + * which corresponds to a particular job step within the hierarchy + * @param stepctx step context string for this step + * @param data data + * @constructor + */ +function WorkflowStepInfo(stepctx,data){ + "use strict"; + var self = this; + /** + * The step context + */ + self.stepctx = stepctx; + /** + * the job referenced by this step + * @type {null} + */ + self.job = data.job; + /** + * the job referenced by an error handler + */ + self.ehJob = data.ehJob; + /** + * The JobStepInfo containing the details of the step + */ + self.jobstep = ko.observable(); + /** + * Job ID + */ + self.jobId = ko.observable(data.id); + /** + * ID of errorhandler Job + */ + self.ehJobId = ko.observable(data.ehId); + /** + * Step type info + */ + self.type = ko.observable(data.type||'.'); + /** + * Step identity string + */ + self.stepident = ko.observable(data.stepident||'Step: '+stepctx); + + /** + * When a JobStepInfo is set for this step, update our details + */ + self.jobstep.subscribe(function(newval){ + if(newval) { + self.type(newval.type()); + self.stepident(newval.stepident()); + self.jobId(newval.jobId()); + self.ehJobId(newval.ehJobId()); + } + }); +} +/** + * Cache of loaded job workflow data, keyed by job ID + * @param url remote URL for loading job workflow data + * @param data any preloaded data + * @constructor + */ +function JobWorkflowsCache(url,data){ + "use strict"; + var self=this; + /** + * Remote url + */ + self.url=url; + /** + * loaded data: ID -> ko.observable(Object) + */ + self.jobs=jQuery.extend({},data); + /** + * Load remote data for a job ID, add it to the cache + * @param id + * @returns {*} + */ + self.load=function(id){ + return jQuery.ajax({ + url:_genUrl(self.url,{id:id}), + method:'GET', + contentType:'json', + success:function(data){ + // setTimeout(function(){ + self.add(id,data.workflow); + // },2000); + } + }); + }; + /** + * add job ID data + * @param id job ID + * @param value data + */ + self.add=function(id,value){ + if(self.jobs[id] && ko.isObservable(self.jobs[id])){ + self.jobs[id](value); + }else { + self.jobs[id] = ko.observable(value); + } + }; + /** + * One time subscription to observable, will be disposed after first call + * @param obs observable + * @param then callback + */ + self.tempSubscribe=function(obs,then){ + var sub; + sub=obs.subscribe(function(data){ + sub.dispose(); + then(data); + }); + }; + /** + * Get data for an ID + * @param id ID + * @param then callback for result of data, may be called immediately if data is available + */ + self.getJob=function(id,then){ + if(self.jobs[id] && ko.isObservable(self.jobs[id]) && self.jobs[id]()){ + then(self.jobs[id]()); + }else if(ko.isObservable(self.jobs[id])){ + self.tempSubscribe(self.jobs[id],then); + }else{ + self.jobs[id]=ko.observable(); + self.tempSubscribe(self.jobs[id],then); + self.load(id); + } + }; +} + +/** + * Manage display data for execution workflow steps + * @param parent owner is a NodeFlowViewModel + * @param data config data: 'dynamicStepDescriptionDisabled' (true/false) if true, do not load any dynamic data. + * 'workflow' preloaded top level workflow info + * 'id' job/execution ID + * @constructor + */ +function MultiWorkflow(parent,data){ + "use strict"; + var self=this; + /** + * owner is a NodeFlowViewModel + */ + self.parent=parent; + /** + * If true, do not load data dynamically + */ + self.dynamicStepDescriptionDisabled=data.dynamicStepDescriptionDisabled; + /** + * Cache for loaded data + * @type {JobWorkflowsCache} + */ + self.cache=new JobWorkflowsCache(data.url,{}); + /** + * JobWorkflow if initial workflow is loaded + * @type {JobWorkflow} + */ + self.job=null; + /** + * ID of job or execution + */ + self.jobId=data.id; + /** + * Placeholder indicating a JobWorkflow data has or will be loaded. Object: Job ID->JobWorkflow + * @type {{}} + */ + self.workflowsets={}; + /** + * context-string-id -> WorkflowStepInfo + * @type {{}} + */ + self.stepinfoset={}; + + /** + * Load or return a JobWorkflow with all workflow data filled in + * @param id job ID + * @param callback called with the JobWorkflow as a parameter after loading + * @returns {JobWorkflow} + */ + self.loadJob=function(id,callback){ + if(self.workflowsets[id] && self.workflowsets[id].workflow.length>0){ + //job data was already loaded, so return existing + callback(self.workflowsets[id]); + return self.workflowsets[id]; + } + //create or retrieve an entry in the workflowsets indicating loading for the job will start + self.workflowsets[id] = self.workflowsets[id] || new JobWorkflow(self, [], id); + var job = self.workflowsets[id]; + //ask cache to load or return cached data + self.cache.getJob(id,function(data){ + //if this is the first time loading this data, we need to fill in the workflow for the job + var job = self.fillJobWorkflow(id,data); + //result will be JobWorkflow + callback(job); + }); + return job; + }; + + /** + * Given ID and possible preloaded workflow data, update the job cache + * @param id job ID + * @param workflow preloaded workflow, or null may cause remote load if not already in the cache + */ + self.updateJobCache=function(id,workflow){ + if(id && !self.workflowsets[id]){ + if(workflow) { + //if workflow is available, put it in the cache + self.cache.add(id, workflow); + } + self.loadJob(id,function(){}); + } + }; + /** + * Fill in a JobWorkflow's workflow given ID and steps, if not already filled in + * @param id job ID + * @param steps load workflow step data + * @returns {JobWorkflow} + */ + self.fillJobWorkflow=function (id, steps) { + "use strict"; + if(self.workflowsets[id] && self.workflowsets[id].workflow.length>0){ + //job data was already loaded, so return existing + return self.workflowsets[id]; + } + var job = self.workflowsets[id] || new JobWorkflow(self, [], id); + self.workflowsets[id] = job; + + for (var x = 0; x < steps.length; x++) { + var stepdata={ + type: _wfTypeForStep(steps[x]), + stepident: _wfStringForStep(steps[x]), + id:steps[x].jobId + }; + if(steps[x].ehJobId){ + //errorhandler job id + stepdata.ehId=steps[x].ehJobId; + } + + job.insert(x,new JobStepInfo(job,x,stepdata)); + + if (stepdata.type == 'job') { + //load job reference if not already in progress + self.updateJobCache(stepdata.id,steps[x].workflow); + } + //do the same for error handler job reference + if (stepdata.ehId) { + self.updateJobCache(stepdata.ehId,steps[x].ehWorkflow); + } + } + + return job; + }; + + /** + * Get stepctx info for a parent job reference, with loaded workflow for the job + * @param parentctx + * @param callback called with WorkflowStepInfo parameter + */ + self.getParentJobStepInfoForStepctx=function(parentctx,callback){ + if(parentctx) { + //load higher level + self.getStepInfoForStepctx(parentctx, function (parentinfo) { + if(parentinfo.type()=='job' && parentinfo.jobId()){ + //load subjob for this step + self.loadJob(parentinfo.jobId(),function(job){ + parentinfo.job=job; + if(parentinfo.ehJobId()) { + self.loadJob(parentinfo.ehJobId(), function (job) { + parentinfo.ehJob = job; + callback(parentinfo); + }); + }else { + callback(parentinfo); + } + }); + }else if(parentinfo.ehJobId()){ + self.loadJob(parentinfo.ehJobId(),function(job){ + parentinfo.ehJob=job; + callback(parentinfo); + }); + }else{ + console.log("stepctx was not job: "+parentctx,parentinfo); + //callback(stepinfo); + } + }); + }else { + //parent is top level job + self.loadJob(self.jobId, function(job){ + callback(new WorkflowStepInfo('',{id:self.jobId,type:'job',job:job})); + }); + } + }; + /** + * Look up step context info for a context string. If not dynamic, returns the placeholder object, + * otherwise looks up step info dynamically and returns WorkflowStepInfo via callback + * @param stepctx context string + * @param callback called with WorkflowStepInfo + * @returns {*} WorkflowStepInfo which may not have loaded contents, or placeholder object + */ + self.getStepInfoForStepctx=function(stepctx,callback){ + "use strict"; + if(self.dynamicStepDescriptionDisabled){ + return { + type:self.parent.workflow.contextType(stepctx), + stepident:self.parent.workflow.renderContextString(stepctx) + }; + } + if(self.stepinfoset[stepctx]){ + + var stepinfo = self.stepinfoset[stepctx]; + if(typeof(callback)=='function' && stepinfo.jobstep()){ + callback(stepinfo); + }else{ + var remove; + remove=stepinfo.jobstep.subscribe(function(newval){ + if(newval) { + remove.dispose(); + callback(stepinfo); + } + }); + } + return stepinfo; + } + var info = new WorkflowStepInfo(stepctx,{}); + self.stepinfoset[stepctx] = info; + var ctx = RDWorkflow.parseContextId(stepctx); + var lastctx=ctx.pop(); + var ndx = RDWorkflow.workflowIndexForContextId(lastctx); + + //get the parent workflow, and then fill in the current step + self.getParentJobStepInfoForStepctx(ctx.join('/'),function(parentjobinfo){ + //TODO: currently step state context string does not indicate errorHandler, but + //in case it does in the future, force use of correct job info + var iseh=ctx.length>0?RDWorkflow.isErrorhandlerForContextId(ctx[ctx.length-1]):false; + var job = iseh?parentjobinfo.ehJob:(parentjobinfo.job||parentjobinfo.ehJob); + var jobstep = job && job.workflow[ndx]; + info.parent=job; + info.jobstep(jobstep); + + if(typeof(callback)=='function'){ + callback(info); + } + }); + + return info; + }; + /** + * Initialize with preloaded data + * @param data + */ + self.initialLoad=function(data){ + //only trigger load if containing a preloaded workflow + if(data.workflow) { + self.updateJobCache(data.id, data.workflow); + } + }; + self.initialLoad(data); +} /** * Represents a (node, stepctx) state. * @param data state data object @@ -29,9 +434,10 @@ function RDNodeStep(data, node, flow){ self.node = node; self.flow = flow; self.stepctx = data.stepctx; - self.type = flow.workflow.contextType(data.stepctx); - self.stepident = flow.workflow.renderContextString(data.stepctx); - self.stepctxdesc = "Workflow Step: " + data.stepctx; + self.stepinfo=ko.observable(flow.multiWorkflow.getStepInfoForStepctx(data.stepctx)); + self.type = ko.observable(flow.workflow.contextType(data.stepctx)); + self.stepident = ko.observable(flow.workflow.renderContextString(data.stepctx)); + self.stepctxdesc = ko.observable("Workflow Step: " + data.stepctx); self.parameters = ko.observable(data.parameters || null); self.followingOutput = ko.observable(false); self.outputLineCount = ko.observable(-1); @@ -294,9 +700,10 @@ function RDNode(name, steps,flow){ } } -function NodeFlowViewModel(workflow,outputUrl,nodeStateUpdateUrl){ +function NodeFlowViewModel(workflow,outputUrl,nodeStateUpdateUrl,mwdata){ var self=this; self.workflow=workflow; + self.multiWorkflow=new MultiWorkflow(self,mwdata); self.errorMessage=ko.observable(); self.statusMessage=ko.observable(); self.stateLoaded=ko.observable(false); @@ -643,7 +1050,8 @@ function NodeFlowViewModel(workflow,outputUrl,nodeStateUpdateUrl){ //var data = model.nodes[node]; var nodeSummary = model.nodeSummaries[node]; - var nodesteps =null;//= model.steps && model.steps.length>0?self.extractNodeStepStates(node,data,model):null; + var nodesteps =null;//= model.steps && + // model.steps.length>0?self.extractNodeStepStates(node,data,model):null; if(!nodesteps && model.nodeSteps && model.nodeSteps[node]){ nodesteps=model.nodeSteps[node]; } diff --git a/rundeckapp/grails-app/assets/javascripts/workflow.js b/rundeckapp/grails-app/assets/javascripts/workflow.js index 3d384841af4..44dc3c7ba38 100644 --- a/rundeckapp/grails-app/assets/javascripts/workflow.js +++ b/rundeckapp/grails-app/assets/javascripts/workflow.js @@ -13,37 +13,70 @@ /** * Represents a workflow */ +function _wfTypeForStep(step){ + "use strict"; + if (typeof(step) != 'undefined') { + if (step['exec']) { + return 'command'; + } else if (step['jobref']) { + return 'job'; + } else if (step['script']) { + return 'script'; + } else if (step['scriptfile']) { + return 'scriptfile'; + } else if (step['type']) {//plugin + if (step['nodeStep'] ) { + return 'node-step-plugin plugin'; + } else if (null != step['nodeStep'] && !step['nodeStep'] ) { + return 'workflow-step-plugin plugin'; + }else{ + return 'plugin'; + } + } + } + return 'console'; +} +function _wfStringForStep(step){ + "use strict"; + var string = ""; + if (typeof(step) != 'undefined') { + if(step['description']){ + string = step['description']; + }else if (step['exec']) { +// string+=' $ '+step['exec']; + string = 'Command'; + } else if (step['jobref']) { + string = (step['jobref']['group'] ? step['jobref']['group'] + '/' : '') + step['jobref']['name']; + } else if (step['script']) { + string = "Script"; + } else if (step['scriptfile']) { + string = step['scriptfile']; + } else if (step['type']) {//plugin + var title = "Plugin " + step['type']; + if (step['nodeStep'] && RDWorkflow.nodeSteppluginDescriptions && RDWorkflow.nodeSteppluginDescriptions[step['type']]) { + title = RDWorkflow.nodeSteppluginDescriptions[step['type']].title || title; + } else if (!step['nodeStep'] && RDWorkflow.wfSteppluginDescriptions && RDWorkflow.wfSteppluginDescriptions[step['type']]) { + title = RDWorkflow.wfSteppluginDescriptions[step['type']].title || title; + } + string = title; + } + }else{ + return "[?]"; + } + return string; +} var RDWorkflow = Class.create({ workflow:null, - nodeSteppluginDescriptions:null, - wfSteppluginDescriptions:null, initialize: function(wf,params){ this.workflow=wf; Object.extend(this, params); }, contextType: function (ctx) { - var string = ""; - var step = this.workflow[RDWorkflow.workflowIndexForContextId(ctx[0])]; - if (typeof(step) != 'undefined') { - if (step['exec']) { - return 'command'; - } else if (step['jobref']) { - return 'job'; - } else if (step['script']) { - return 'script'; - } else if (step['scriptfile']) { - return 'scriptfile'; - } else if (step['type']) {//plugin - if (step['nodeStep'] ) { - return 'node-step-plugin plugin'; - } else if (null != step['nodeStep'] && !step['nodeStep'] ) { - return 'workflow-step-plugin plugin'; - }else{ - return 'plugin'; - } - } + if(typeof(ctx)=='string'){ + ctx= RDWorkflow.parseContextId(ctx); } - return 'console'; + var step = this.workflow[RDWorkflow.workflowIndexForContextId(ctx[0])]; + return _wfTypeForStep(step); }, renderContextStepNumber: function (ctx) { if (typeof(ctx) == 'string') { @@ -61,33 +94,8 @@ var RDWorkflow = Class.create({ if(typeof(ctx)=='string'){ ctx= RDWorkflow.parseContextId(ctx); } - var string = ""; var step = this.workflow[RDWorkflow.workflowIndexForContextId(ctx[0])]; - if (typeof(step) != 'undefined') { - if(step['description']){ - string = step['description']; - }else if (step['exec']) { -// string+=' $ '+step['exec']; - string = 'Command'; - } else if (step['jobref']) { - string = (step['jobref']['group'] ? step['jobref']['group'] + '/' : '') + step['jobref']['name']; - } else if (step['script']) { - string = "Script"; - } else if (step['scriptfile']) { - string = step['scriptfile']; - } else if (step['type']) {//plugin - var title = "Plugin " + step['type']; - if (step['nodeStep'] && this.nodeSteppluginDescriptions && this.nodeSteppluginDescriptions[step['type']]) { - title = this.nodeSteppluginDescriptions[step['type']].title || title; - } else if (!step['nodeStep'] && this.wfSteppluginDescriptions && this.wfSteppluginDescriptions[step['type']]) { - title = this.wfSteppluginDescriptions[step['type']].title || title; - } - string = title; - } - }else{ - return "[?]"; - } - return string; + return _wfStringForStep(step); } }); /** @@ -164,13 +172,7 @@ RDWorkflow.parseContextId= function (context) { } //split context into project,type,object var t = RDWorkflow.splitEscaped(context,RDWorkflow.contextStringSeparator); - var i = 0; - var vals = new Array(); - for (i = 0; i < t.length; i++) { - var x = t[i]; - vals.push(x); - } - return vals; + return t.slice(); }; /** diff --git a/rundeckapp/grails-app/assets/javascripts/workflow.test.js b/rundeckapp/grails-app/assets/javascripts/workflow.test.js index c702196fec4..52650994743 100644 --- a/rundeckapp/grails-app/assets/javascripts/workflow.test.js +++ b/rundeckapp/grails-app/assets/javascripts/workflow.test.js @@ -72,40 +72,38 @@ RDWorkflow.test = function () { console.assert(RDWorkflow.cleanContextId('1e@abc/2/3')==='1/2/3','wrong value'); console.assert(RDWorkflow.cleanContextId('1/2e@asdf=xyz/3')==='1/2/3','wrong value'); - //render string, with descriptions - var wf1=new RDWorkflow([{"type":"example-node-step","nodeStep":true,"configuration":{"example":"whatever"}}],{ - nodeSteppluginDescriptions:{ + var orig=RDWorkflow.nodeSteppluginDescriptions; + var orig2=RDWorkflow.wfSteppluginDescriptions; + RDWorkflow.nodeSteppluginDescriptions={}; + RDWorkflow.wfSteppluginDescriptions={}; + RDWorkflow.nodeSteppluginDescriptions={ "example-node-step":{ - "title":"blah" - } - }, - wfSteppluginDescriptions:{} - }); + "title":"blah" + } + }; + //render string, with descriptions + var wf1=new RDWorkflow([{"type":"example-node-step","nodeStep":true,"configuration":{"example":"whatever"}}]); console.assert(wf1.renderContextString("1") === "blah"); - var wf2=new RDWorkflow([{"type":"example-node-step","nodeStep":false,"configuration":{"example":"whatever"}}],{ - nodeSteppluginDescriptions:{}, - wfSteppluginDescriptions:{ - "example-node-step":{ - "title":"blah" - } + RDWorkflow.wfSteppluginDescriptions={ + "example-node-step":{ + "title":"blah" } - }); + }; + var wf2=new RDWorkflow([{"type":"example-node-step","nodeStep":false,"configuration":{"example":"whatever"}}]); console.assert(wf2.renderContextString("1") === "blah"); + RDWorkflow.nodeSteppluginDescriptions={}; + RDWorkflow.wfSteppluginDescriptions={}; //render string, missing descriptions - var wf3=new RDWorkflow([{"type":"example-node-step","nodeStep":true,"configuration":{"example":"whatever"}}],{ - nodeSteppluginDescriptions:{}, - wfSteppluginDescriptions:{} - }); + var wf3=new RDWorkflow([{"type":"example-node-step","nodeStep":true,"configuration":{"example":"whatever"}}]); console.assert(wf3.renderContextString("1") === "Plugin example-node-step"); - var wf4=new RDWorkflow([{"type":"example-node-step","nodeStep":false,"configuration":{"example":"whatever"}}],{ - nodeSteppluginDescriptions:{}, - wfSteppluginDescriptions:{} - }); + var wf4=new RDWorkflow([{"type":"example-node-step","nodeStep":false,"configuration":{"example":"whatever"}}]); console.assert(wf4.renderContextString("1") === "Plugin example-node-step"); + RDWorkflow.nodeSteppluginDescriptions=orig; + RDWorkflow.wfSteppluginDescriptions=orig; }; jQuery(function () { diff --git a/rundeckapp/grails-app/controllers/rundeck/controllers/ScheduledExecutionController.groovy b/rundeckapp/grails-app/controllers/rundeck/controllers/ScheduledExecutionController.groovy index 366c777e5bc..ee1ec0f1893 100644 --- a/rundeckapp/grails-app/controllers/rundeck/controllers/ScheduledExecutionController.groovy +++ b/rundeckapp/grails-app/controllers/rundeck/controllers/ScheduledExecutionController.groovy @@ -451,6 +451,81 @@ class ScheduledExecutionController extends ControllerBase{ dataMap } + public def workflowJson (){ + def ScheduledExecution scheduledExecution = scheduledExecutionService.getByIDorUUID( params.id ) + + if (notFoundResponse(scheduledExecution, 'Job', params.id)) { + return + } + AuthContext authContext = frameworkService.getAuthContextForSubjectAndProject( + session.subject, + scheduledExecution.project + ) + if (unauthorizedResponse( + frameworkService.authorizeProjectJobAll( + authContext, + scheduledExecution, + [AuthConstants.ACTION_READ], + scheduledExecution.project + ), + AuthConstants.ACTION_READ, 'Job', params.id + )) { + return + } + def maxDepth=3 + def jobids=[:] + def cmdData={} + cmdData={x,WorkflowStep step-> + def map=step.toMap() + if(step instanceof JobExec) { + ScheduledExecution refjob = ScheduledExecution.findByProjectAndJobNameAndGroupPath( + scheduledExecution.project, + step.jobName, + step.jobGroup + ) + if(refjob){ + map.jobId=refjob.extid + boolean doload=(null==jobids[map.jobId]) + if(doload){ + jobids[map.jobId]=[] + } + if(doload && x>0){ + map.workflow=jobids[map.jobId] + jobids[map.jobId].addAll(refjob.workflow.commands.collect(cmdData.curry(x-1))) + } + } + } + def eh = step.errorHandler + + if(eh instanceof JobExec) { + ScheduledExecution refjob = ScheduledExecution.findByProjectAndJobNameAndGroupPath( + scheduledExecution.project, + eh.jobName, + eh.jobGroup + ) + if(refjob){ + map.ehJobId=refjob.extid + boolean doload=(null==jobids[map.ehJobId]) + if(doload){ + jobids[map.ehJobId]=[] + } + if(doload && x>0){ + map.ehWorkflow=jobids[map.ehJobId] + jobids[map.ehJobId].addAll(refjob.workflow.commands.collect(cmdData.curry(x-1))) + } + } + } + return map + } + def wfdata=scheduledExecution.workflow.commands.collect(cmdData.curry(maxDepth)) + withFormat { + json { + render(contentType: 'application/json') { + workflow= wfdata + } + } + } + } /** * Sanitize the html text submitted * @return diff --git a/rundeckapp/grails-app/views/common/_js.gsp b/rundeckapp/grails-app/views/common/_js.gsp index f927509b002..e879b8b2e01 100644 --- a/rundeckapp/grails-app/views/common/_js.gsp +++ b/rundeckapp/grails-app/views/common/_js.gsp @@ -37,6 +37,7 @@ scheduledExecutionDetailFragment: '${createLink(controller:'scheduledExecution',action:'detailFragment',params: projParams)}', scheduledExecutionDetailFragmentAjax: '${createLink(controller:'scheduledExecution',action:'detailFragmentAjax',params: projParams)}', scheduledExecutionSanitizeHtml: '${createLink(controller:'scheduledExecution',action:'sanitizeHtml',params: projParams)}', + scheduledExecutionWorkflowJson: '${createLink(controller:'scheduledExecution',action:'workflowJson',params: projParams)}', executionFollowFragment: "${createLink(controller:'execution',action:'followFragment',params:projParams)}", adhocHistoryAjax: "${createLink(controller:'execution',action:'adhocHistoryAjax',params:projParams)}", menuJobs: "${createLink(controller:'menu',action:'jobs',params: projParams)}", diff --git a/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp b/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp index 52a90cfccb7..ccf08999b66 100644 --- a/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp +++ b/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp @@ -22,7 +22,7 @@ - - + + diff --git a/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp b/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp index 5c5f214f0ec..1d6e956c24a 100644 --- a/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp +++ b/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp @@ -97,8 +97,8 @@
- - + + (Next up) @@ -125,8 +125,8 @@
- - + +
diff --git a/rundeckapp/grails-app/views/execution/show.gsp b/rundeckapp/grails-app/views/execution/show.gsp index c0b8497c132..d3552d2ea7f 100644 --- a/rundeckapp/grails-app/views/execution/show.gsp +++ b/rundeckapp/grails-app/views/execution/show.gsp @@ -33,6 +33,7 @@ + @@ -58,11 +59,11 @@ var activity; function init() { + var execInfo=loadJsonData('execInfoJSON'); var workflowData=loadJsonData('workflowDataJSON'); - workflow = new RDWorkflow(workflowData,{ - nodeSteppluginDescriptions:loadJsonData('nodeStepPluginsJSON'), - wfSteppluginDescriptions:loadJsonData('wfStepPluginsJSON') - }); + RDWorkflow.nodeSteppluginDescriptions=loadJsonData('nodeStepPluginsJSON'); + RDWorkflow.wfSteppluginDescriptions=loadJsonData('wfStepPluginsJSON'); + workflow = new RDWorkflow(workflowData); followControl = new FollowControl('${execution?.id}','outputappendform',{ parentElement:'commandPerform', @@ -98,7 +99,13 @@ nodeflowvm=new NodeFlowViewModel( workflow, "${enc(js:g.createLink(controller: 'execution', action: 'tailExecutionOutput', id: execution.id,params:[format:'json']))}", - "${enc(js:g.createLink(controller: 'execution', action: 'ajaxExecNodeState', id: execution.id))}" + "${enc(js:g.createLink(controller: 'execution', action: 'ajaxExecNodeState', id: execution.id))}", + { + dynamicStepDescriptionDisabled:false, + url:appLinks.scheduledExecutionWorkflowJson, + id:execInfo.jobId||execInfo.execId,//id of job or execution + workflow:execInfo.jobId?null:workflowData + } ); flowState = new FlowState('${enc(js:execution?.id)}','flowstate',{ workflow:workflow, From e28dbeaf3ed779cc3551d6c6c219e1f6b41c04e0 Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Wed, 29 Jun 2016 14:19:18 -0700 Subject: [PATCH 02/11] update workflow tests --- .../assets/javascripts/util/testing.js | 9 + .../assets/javascripts/workflow.test.js | 236 ++++++++++-------- 2 files changed, 139 insertions(+), 106 deletions(-) diff --git a/rundeckapp/grails-app/assets/javascripts/util/testing.js b/rundeckapp/grails-app/assets/javascripts/util/testing.js index 3b11bf5e035..71f4985eca3 100644 --- a/rundeckapp/grails-app/assets/javascripts/util/testing.js +++ b/rundeckapp/grails-app/assets/javascripts/util/testing.js @@ -54,6 +54,15 @@ var TestHarness = function (name,data) { }; self.assert = function (msg, expect, val) { total++; + if(null==expect && null==val && typeof(msg)!='string'){ + expect=true; + val=msg; + msg='(assert)'; + }else if(null==val && typeof(expect)=='string' && typeof(msg)=='boolean'){ + val=msg; + msg=expect; + expect=true; + } if (!self.compare(expect,val)) { failed++; var message = "FAIL: " +curPrefix+ msg + ": expected: " + JSON.stringify(expect) + ", was: " + JSON.stringify(val); diff --git a/rundeckapp/grails-app/assets/javascripts/workflow.test.js b/rundeckapp/grails-app/assets/javascripts/workflow.test.js index 52650994743..3749fba5048 100644 --- a/rundeckapp/grails-app/assets/javascripts/workflow.test.js +++ b/rundeckapp/grails-app/assets/javascripts/workflow.test.js @@ -1,111 +1,135 @@ //= require workflow +//= require util/testing -RDWorkflow.assertObjEq = function (arr1,arr2) { - "use strict"; - console.assert(arr1.length==arr2.length,arr1,arr2); - if(arr1.length!=arr2.length){ - return false; - } - for(var prop in arr1){ - if(arr1.hasOwnProperty(prop)){ - console.assert(arr1[prop]==arr2[prop],arr1[prop],arr2[prop],"prop "+prop); - } - } - return true; -}; -RDWorkflow.assertArrayEq = function (arr1,arr2) { - "use strict"; - - console.assert(arr1.length==arr2.length,arr1,arr2); - if(arr1.length!=arr2.length){ - return false; - } - for(var i=0;i Date: Thu, 30 Jun 2016 11:59:58 -0700 Subject: [PATCH 03/11] ko binding-popover fix: observable value to refresh title --- .../assets/javascripts/ko/binding-popover.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/rundeckapp/grails-app/assets/javascripts/ko/binding-popover.js b/rundeckapp/grails-app/assets/javascripts/ko/binding-popover.js index ff724b706f6..fe22a47c905 100644 --- a/rundeckapp/grails-app/assets/javascripts/ko/binding-popover.js +++ b/rundeckapp/grails-app/assets/javascripts/ko/binding-popover.js @@ -17,11 +17,23 @@ ko.bindingHandlers.bootstrapPopover = { /** * Initializes bootstrap tooltip on the dom element. Usage: <div data-bind="bootstrapTooltip: true" title="blah" > + * tip: if the title of the element is bound to an observable, pass the same one as the binding, like + * <div data-bind="bootstrapTooltip: mytooltipObservable" title="blah" >, to trigger updates when it changes. * @type {{init: ko.bindingHandlers.bootstrapTooltip.init}} */ ko.bindingHandlers.bootstrapTooltip = { init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { "use strict"; jQuery(element).tooltip({}); + }, + update: function (element, valueAccessor, allBindings, viewModel, bindingContext) { + "use strict"; + var val = valueAccessor(); + if(ko.isObservable(val)){ + val = ko.unwrap(val); + jQuery(element).tooltip('destroy'); + jQuery(element).data('original-title',null); + jQuery(element).tooltip({}); + } } }; From 627ef09891bafeffa18c2fdd63c3ee0725f4379d Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Thu, 30 Jun 2016 12:09:11 -0700 Subject: [PATCH 04/11] Improvements to execution page UI for workflow steps * monitor steps show full workflow path on hover * clickable links for full path of step * output pane loads correct step info for each line --- .../assets/javascripts/executionStateKO.js | 206 ++++++++++++++++-- .../controllers/ExecutionController.groovy | 13 +- .../taglib/rundeck/UtilityTagLib.groovy | 2 +- .../execution/_nodeCurrentStateSimpleKO.gsp | 3 +- .../execution/_wfstateNodeModelDisplay.gsp | 28 ++- .../grails-app/views/execution/show.gsp | 98 ++++++++- rundeckapp/web-app/js/executionControl.js | 25 ++- 7 files changed, 332 insertions(+), 43 deletions(-) diff --git a/rundeckapp/grails-app/assets/javascripts/executionStateKO.js b/rundeckapp/grails-app/assets/javascripts/executionStateKO.js index eb71602feac..c0633e381d4 100644 --- a/rundeckapp/grails-app/assets/javascripts/executionStateKO.js +++ b/rundeckapp/grails-app/assets/javascripts/executionStateKO.js @@ -1,6 +1,8 @@ //= require momentutil //= require knockout.min //= require knockout-mapping +//= require ko/binding-popover +//= require ko/binding-url-path-param /* Copyright 2014 SimplifyOps Inc, @@ -34,8 +36,11 @@ function JobStepInfo(parent,ndx,data){ self.ndx = ndx; self.jobId = ko.observable(data.id); self.ehJobId = ko.observable(data.ehId); - self.type = ko.observable(data.type||'.'); + self.type = ko.observable(data.type||'unknown-step-type'); self.stepident = ko.observable(data.stepident||'(Unknown)'); + self.ehType = ko.observable(data.ehType); + self.ehStepident = ko.observable(data.ehStepident); + self.ehKeepgoingOnSuccess = ko.observable(data.ehKeepgoingOnSuccess); } /** @@ -68,13 +73,18 @@ function JobWorkflow(multi,workflow,id){ * @param data data * @constructor */ -function WorkflowStepInfo(stepctx,data){ +function WorkflowStepInfo(multiworkflow,stepctx,data){ "use strict"; var self = this; + /** + * The MultiWorkflow + */ + self.multiworkflow=multiworkflow; /** * The step context */ self.stepctx = stepctx; + self.stepctxArray = ko.observableArray(RDWorkflow.parseContextId(stepctx)); /** * the job referenced by this step * @type {null} @@ -99,12 +109,168 @@ function WorkflowStepInfo(stepctx,data){ /** * Step type info */ - self.type = ko.observable(data.type||'.'); + self.type = ko.observable(data.type||'unknown-step-type'); + /** + * Step type info + */ + self.ehType = ko.observable(data.ehType); /** * Step identity string */ self.stepident = ko.observable(data.stepident||'Step: '+stepctx); + /** + * Error handler step identity + */ + self.ehStepident = ko.observable(data.ehStepident); + self.ehKeepgoingOnSuccess = ko.observable(data.ehKeepgoingOnSuccess); + + self.hasParent=ko.pureComputed(function(){ + return self.stepctxArray().length>1; + }); + + /** + * Return true if this step is a job ref step or has a parent job + */ + self.hasLink=ko.pureComputed(function () { + var has = self.hasParent(); + var isjob = self.type() == 'job'; + return has || isjob; + }); + + /** + * Return appropriate JobID for linking this step, + * for a non-job reference, this is the parent Job ID. + * For a + */ + self.linkJobId=ko.pureComputed(function(){ + if(self.type()=='job'){ + return self.jobId(); + }else if(self.hasParent()){ + return self.parentJobId(); + }else{ + return self.multiworkflow.jobId; + } + }); + /** + * Return the title for linked job + */ + self.linkTitle=ko.pureComputed(function(){ + if(self.type()=='job'){ + return self.stepident(); + }else if(self.hasParent()){ + return self.parentJobTitle(); + }else{ + return 'Current Job'; + } + }); + self.parentJobId=ko.pureComputed(function(){ + var has=self.hasParent(); + var parent=self.parentStepInfo(); + if(has && parent){ + if(parent.isErrorhandler()){ + return parent.ehJobId(); + } + return parent.jobId()||parent.ehJobId(); + } + return null; + }); + self.parentJobTitle=ko.pureComputed(function(){ + var has=self.hasParent(); + var parent=self.parentStepInfo(); + if(has && parent){ + if(parent.isErrorhandler()){ + return parent.ehStepident(); + } + return parent.jobId() && parent.stepident() ||parent.ehStepident(); + } + return null; + }); + self.parentStepInfo=ko.computed(function(){ + var ctx=self.stepctxArray(); + if(ctx.length>1) { + var parent = ctx.slice(0, -1).join('/'); + return self.multiworkflow.getStepInfoForStepctx(parent); + } + return null; + }); + /** + * Step number + */ + self.stepnum=ko.pureComputed(function(){ + var stepctxArray = self.stepctxArray(); + if(stepctxArray.length>0){ + return RDWorkflow.stepNumberForContextId(stepctxArray[stepctxArray.length-1]); + }else{ + return null; + } + }); + /** + * Step is error handler + */ + self.isErrorhandler=ko.pureComputed(function(){ + var stepctxArray = self.stepctxArray(); + if(stepctxArray.length>0){ + for(var i =0;i1){ + var obj=self.parentStepInfo(); + return obj.stepctxPathFull()+' / ' + // + ctx[ctx.length-1] + + self.stepdescFull() + ; + }else{ + return self.stepdescFull(); + } + }); /** * When a JobStepInfo is set for this step, update our details */ @@ -114,6 +280,9 @@ function WorkflowStepInfo(stepctx,data){ self.stepident(newval.stepident()); self.jobId(newval.jobId()); self.ehJobId(newval.ehJobId()); + self.ehType(newval.ehType()); + self.ehStepident(newval.ehStepident()); + self.ehKeepgoingOnSuccess(newval.ehKeepgoingOnSuccess()); } }); } @@ -201,13 +370,13 @@ function JobWorkflowsCache(url,data){ * 'id' job/execution ID * @constructor */ -function MultiWorkflow(parent,data){ +function MultiWorkflow(workflowInfo,data){ "use strict"; var self=this; /** - * owner is a NodeFlowViewModel + * workflowInfo is a RDWorkflow */ - self.parent=parent; + self.workflowInfo=workflowInfo; /** * If true, do not load data dynamically */ @@ -297,6 +466,12 @@ function MultiWorkflow(parent,data){ stepident: _wfStringForStep(steps[x]), id:steps[x].jobId }; + if(steps[x].errorhandler){ + //errorhandler info for the job + stepdata.ehType=_wfTypeForStep(steps[x].errorhandler); + stepdata.ehStepident=_wfStringForStep(steps[x].errorhandler); + stepdata.ehKeepgoingOnSuccess=_wfStringForStep(steps[x].errorhandler.keepgoingOnSuccess); + } if(steps[x].ehJobId){ //errorhandler job id stepdata.ehId=steps[x].ehJobId; @@ -352,7 +527,7 @@ function MultiWorkflow(parent,data){ }else { //parent is top level job self.loadJob(self.jobId, function(job){ - callback(new WorkflowStepInfo('',{id:self.jobId,type:'job',job:job})); + callback(new WorkflowStepInfo(self,'',{id:self.jobId,type:'job',job:job})); }); } }; @@ -367,8 +542,8 @@ function MultiWorkflow(parent,data){ "use strict"; if(self.dynamicStepDescriptionDisabled){ return { - type:self.parent.workflow.contextType(stepctx), - stepident:self.parent.workflow.renderContextString(stepctx) + type:self.workflowInfo.contextType(stepctx), + stepident:self.workflowInfo.renderContextString(stepctx) }; } if(self.stepinfoset[stepctx]){ @@ -376,7 +551,7 @@ function MultiWorkflow(parent,data){ var stepinfo = self.stepinfoset[stepctx]; if(typeof(callback)=='function' && stepinfo.jobstep()){ callback(stepinfo); - }else{ + }else if (typeof(callback)=='function'){ var remove; remove=stepinfo.jobstep.subscribe(function(newval){ if(newval) { @@ -387,7 +562,7 @@ function MultiWorkflow(parent,data){ } return stepinfo; } - var info = new WorkflowStepInfo(stepctx,{}); + var info = new WorkflowStepInfo(self,stepctx,{}); self.stepinfoset[stepctx] = info; var ctx = RDWorkflow.parseContextId(stepctx); var lastctx=ctx.pop(); @@ -395,7 +570,7 @@ function MultiWorkflow(parent,data){ //get the parent workflow, and then fill in the current step self.getParentJobStepInfoForStepctx(ctx.join('/'),function(parentjobinfo){ - //TODO: currently step state context string does not indicate errorHandler, but + //TODO: currently node summary state context string does not indicate errorHandler, but //in case it does in the future, force use of correct job info var iseh=ctx.length>0?RDWorkflow.isErrorhandlerForContextId(ctx[ctx.length-1]):false; var job = iseh?parentjobinfo.ehJob:(parentjobinfo.job||parentjobinfo.ehJob); @@ -440,6 +615,7 @@ function RDNodeStep(data, node, flow){ self.stepctxdesc = ko.observable("Workflow Step: " + data.stepctx); self.parameters = ko.observable(data.parameters || null); self.followingOutput = ko.observable(false); + self.hovering = ko.observable(false); self.outputLineCount = ko.observable(-1); self.startTime = ko.observable(data.startTime || null); self.updateTime = ko.observable(data.updateTime || null); @@ -700,10 +876,10 @@ function RDNode(name, steps,flow){ } } -function NodeFlowViewModel(workflow,outputUrl,nodeStateUpdateUrl,mwdata){ +function NodeFlowViewModel(workflow,outputUrl,nodeStateUpdateUrl,multiworkflow){ var self=this; self.workflow=workflow; - self.multiWorkflow=new MultiWorkflow(self,mwdata); + self.multiWorkflow=multiworkflow; self.errorMessage=ko.observable(); self.statusMessage=ko.observable(); self.stateLoaded=ko.observable(false); @@ -911,7 +1087,7 @@ function NodeFlowViewModel(workflow,outputUrl,nodeStateUpdateUrl,mwdata){ nodestep.followingOutput(false); self.followingStep(null); } - + return true; }; self.scrollTo= function (element,offx,offy) { var x = element.x ? element.x : element.offsetLeft, diff --git a/rundeckapp/grails-app/controllers/rundeck/controllers/ExecutionController.groovy b/rundeckapp/grails-app/controllers/rundeck/controllers/ExecutionController.groovy index 224567f8008..457901fed52 100644 --- a/rundeckapp/grails-app/controllers/rundeck/controllers/ExecutionController.groovy +++ b/rundeckapp/grails-app/controllers/rundeck/controllers/ExecutionController.groovy @@ -204,13 +204,12 @@ class ExecutionController extends ControllerBase{ eprev = result ? result[0] : null //load plugins for WF steps def pluginDescs=[node:[:],workflow:[:]] - e.workflow.commands.findAll{it.instanceOf(PluginStep)}.each{PluginStep step-> - if(!pluginDescs[step.nodeStep?'node':'workflow'][step.type]){ - def description = frameworkService.getPluginDescriptionForItem(step) - if (description) { - pluginDescs[step.nodeStep ? 'node' : 'workflow'][step.type]=description - } - } + + frameworkService.getNodeStepPluginDescriptions().each{desc-> + pluginDescs['node'][desc.name]=desc + } + frameworkService.getStepPluginDescriptions().each{desc-> + pluginDescs['workflow'][desc.name]=desc } // def state = workflowService.readWorkflowStateForExecution(e) // if(!state){ diff --git a/rundeckapp/grails-app/taglib/rundeck/UtilityTagLib.groovy b/rundeckapp/grails-app/taglib/rundeck/UtilityTagLib.groovy index 221e1c5246f..a4f07bbf2d8 100644 --- a/rundeckapp/grails-app/taglib/rundeck/UtilityTagLib.groovy +++ b/rundeckapp/grails-app/taglib/rundeck/UtilityTagLib.groovy @@ -1542,7 +1542,7 @@ ansi-bg-default''')) attrs.name=attrs.name.substring('glyphicon-'.length()) } if (glyphiconSet.contains(attrs.name)) { - out << "" + out << "" }else{ if(Environment.current==Environment.DEVELOPMENT) { throw new Exception("icon name not recognized: ${attrs.name}, suggestions: "+(glyphiconSet.findAll{it.contains(attrs.name)||it=~attrs.name})+"?") diff --git a/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp b/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp index ccf08999b66..55ec7097551 100644 --- a/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp +++ b/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp @@ -22,7 +22,6 @@ - - + diff --git a/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp b/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp index 1d6e956c24a..6715267303f 100644 --- a/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp +++ b/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp @@ -96,9 +96,8 @@
- - + data-bind="attr: { 'data-execstate': executionState }"> + (Next up) @@ -120,13 +119,26 @@
-
+
-
+
- - + + + %{----}% + + %{----}% + %{----}% + %{----}% + %{----}% + + +
diff --git a/rundeckapp/grails-app/views/execution/show.gsp b/rundeckapp/grails-app/views/execution/show.gsp index d3552d2ea7f..9ed2f696433 100644 --- a/rundeckapp/grails-app/views/execution/show.gsp +++ b/rundeckapp/grails-app/views/execution/show.gsp @@ -65,6 +65,12 @@ RDWorkflow.wfSteppluginDescriptions=loadJsonData('wfStepPluginsJSON'); workflow = new RDWorkflow(workflowData); + var multiworkflow=new MultiWorkflow(workflow,{ + dynamicStepDescriptionDisabled:false, + url:appLinks.scheduledExecutionWorkflowJson, + id:execInfo.jobId||execInfo.execId,//id of job or execution + workflow:execInfo.jobId?null:workflowData + }); followControl = new FollowControl('${execution?.id}','outputappendform',{ parentElement:'commandPerform', fileloadId:'fileload', @@ -74,6 +80,7 @@ cmdOutputErrorId:'cmdoutputerror', outfileSizeId:'outfilesize', workflow:workflow, + multiworkflow:multiworkflow, appLinks:appLinks, extraParams:"<%="true" == params.disableMarkdown ? '&disableMarkdown=true' : ''%>&markdown=${enc(js:enc(url: params.markdown))}&ansicolor=${enc(js:enc(url: params.ansicolor))}", @@ -100,12 +107,7 @@ workflow, "${enc(js:g.createLink(controller: 'execution', action: 'tailExecutionOutput', id: execution.id,params:[format:'json']))}", "${enc(js:g.createLink(controller: 'execution', action: 'ajaxExecNodeState', id: execution.id))}", - { - dynamicStepDescriptionDisabled:false, - url:appLinks.scheduledExecutionWorkflowJson, - id:execInfo.jobId||execInfo.execId,//id of job or execution - workflow:execInfo.jobId?null:workflowData - } + multiworkflow ); flowState = new FlowState('${enc(js:execution?.id)}','flowstate',{ workflow:workflow, @@ -626,6 +628,90 @@
+ + + + + + + + + + +
diff --git a/rundeckapp/web-app/js/executionControl.js b/rundeckapp/web-app/js/executionControl.js index 00bc935f184..3e021e6c5cd 100644 --- a/rundeckapp/web-app/js/executionControl.js +++ b/rundeckapp/web-app/js/executionControl.js @@ -73,7 +73,8 @@ var FollowControl = Class.create({ smallIconUrl:'/images/icon-small', appLinks: null, workflow:null, - + multiworkflow:null, + initialize: function(eid,elem,params){ this.executionId=eid; this.targetElement=elem; @@ -1140,6 +1141,13 @@ var FollowControl = Class.create({ sp2.addClassName('stepident'); setText(sp, contextstr); cell.appendChild(sp2); + //if dynamic step info available load dynamically + if(this.multiworkflow){ + this.multiworkflow.getStepInfoForStepctx(data['stepctx'],function(info){ + "use strict"; + setText(sp2,info.stepident()); + }); + } } else { tr.addClassName('console'); appendHtml(cell," [console]"); @@ -1261,12 +1269,21 @@ var FollowControl = Class.create({ // tdctx.addClassName('repeat'); }else if(data['stepctx'] && this.workflow){ - var cmdtext= this.workflow.renderContextStepNumber(data['stepctx']) + " " + this.workflow.renderContextString(data['stepctx']); + var stepNumText = this.workflow.renderContextStepNumber(data['stepctx']); + var cmdtext= stepNumText + " " + this.workflow.renderContextString(data['stepctx']); var icon= new Element('i'); - icon.addClassName('rdicon icon-small '+ this.workflow.contextType(data['stepctx'])) + icon.addClassName('rdicon icon-small '+ this.workflow.contextType(data['stepctx'])); tdctx.appendChild(icon); tdctx.appendChild(document.createTextNode(" "+cmdtext)); - tdctx.setAttribute('title', this.workflow.renderContextString(data['stepctx'])); + tdctx.setAttribute('title', data['stepctx']); + if(this.multiworkflow){ + var td = jQuery(tdctx); + var stepinfo=this.multiworkflow.getStepInfoForStepctx(data['stepctx']); + td.empty(); + td.attr('title',null); + td.attr('data-bind',"template: {name: 'step-info-extended', data:$data, as: 'stepinfo'}"); + ko.applyBindings(stepinfo,td[0]); + } } var tddata = $(tr.insertCell(cellndx)); tddata.addClassName('data'); From b1bada912a2ca981622a6449b1a62b71c8ef338e Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Thu, 30 Jun 2016 12:31:49 -0700 Subject: [PATCH 05/11] Add feature flags --- .../services/ConfigurationService.groovy | 24 ++++++ .../services/feature/FeatureService.groovy | 35 ++++++++ .../taglib/rundeck/FeatureTagLib.groovy | 45 ++++++++++ .../services/ConfigurationServiceSpec.groovy | 15 ++++ .../feature/FeatureServiceSpec.groovy | 83 +++++++++++++++++++ 5 files changed, 202 insertions(+) create mode 100644 rundeckapp/grails-app/services/rundeck/services/feature/FeatureService.groovy create mode 100644 rundeckapp/grails-app/taglib/rundeck/FeatureTagLib.groovy create mode 100644 rundeckapp/test/unit/rundeck/services/feature/FeatureServiceSpec.groovy diff --git a/rundeckapp/grails-app/services/rundeck/services/ConfigurationService.groovy b/rundeckapp/grails-app/services/rundeck/services/ConfigurationService.groovy index 2d3023da1c7..329a699c688 100644 --- a/rundeckapp/grails-app/services/rundeck/services/ConfigurationService.groovy +++ b/rundeckapp/grails-app/services/rundeck/services/ConfigurationService.groovy @@ -13,6 +13,15 @@ class ConfigurationService { grailsApplication.config?.rundeck } + public ConfigObject getConfig(String path){ + def strings = path.split('\\.') + def val = appConfig + strings.each { + val = val?."${it}" + } + return val; + } + void setExecutionModeActive(boolean active) { getAppConfig().executionMode = (active ? 'active' : 'passive') } @@ -72,6 +81,21 @@ class ConfigurationService { } booleanValue(defval, val) } + /** + * Set boolean config value, rundeck.some.property.name, to true/false. + * @param property property name + * @param val value to set + */ + def setBoolean(String property, boolean val) { + def strings = property.split('\\.') + def cval = appConfig + if(strings.length>1) { + strings[0..-2].each { + cval = cval.getAt(it) + } + } + cval.putAt(strings[-1],val) + } /** * Lookup boolean config value, rundeck.service.component.property, evaluate true/false. * @param service service name diff --git a/rundeckapp/grails-app/services/rundeck/services/feature/FeatureService.groovy b/rundeckapp/grails-app/services/rundeck/services/feature/FeatureService.groovy new file mode 100644 index 00000000000..3cc1167d093 --- /dev/null +++ b/rundeckapp/grails-app/services/rundeck/services/feature/FeatureService.groovy @@ -0,0 +1,35 @@ +package rundeck.services.feature + +/** + * Manage feature configuration in the 'rundeck.feature.X' namespace, a + * feature '*' enables all features. + */ +class FeatureService { + static transactional = false + def configurationService + /** + * Return true if grails configuration allows given feature, or '*' features + * @param name + * @return + */ + def boolean featurePresent(def name) { + def splat = configurationService.getBoolean('feature.*.enabled', false) + return splat || configurationService.getBoolean("feature.${name}.enabled", false) + } + /** + * Set an incubator feature toggle on or off + * @param name + * @param enable + */ + def void toggleFeature(def name, boolean enable) { + configurationService.setBoolean("feature.${name}.enabled", enable) + } + /** + * Set an incubator feature toggle on or off + * @param name + * @param enable + */ + def getFeatureConfig(def name) { + configurationService.getConfig("feature.${name}.config") + } +} diff --git a/rundeckapp/grails-app/taglib/rundeck/FeatureTagLib.groovy b/rundeckapp/grails-app/taglib/rundeck/FeatureTagLib.groovy new file mode 100644 index 00000000000..baeebc81f56 --- /dev/null +++ b/rundeckapp/grails-app/taglib/rundeck/FeatureTagLib.groovy @@ -0,0 +1,45 @@ +package rundeck + +import rundeck.services.feature.FeatureService + +class FeatureTagLib { + def static namespace = "feature" + def FeatureService featureService + + static returnObjectForTags = ['isEnabled', 'isDisabled'] + /** + * Return true if the feature 'name' is enabled + * @attr name REQUIRED name of feature + */ + def isEnabled = { attrs, body -> + if (!attrs.name) { + throw new Exception("attribute required: 'name'") + } + return featureService.featurePresent(attrs.name) + } + /** + * Render body if the feature is enabled + * @attr name REQUIRED name of feature + */ + def enabled = { attrs, body -> + if (isEnabled(attrs, body)) { + out << body() + } + } + /** + * Return true if the feature 'name' is disabled + * @attr name REQUIRED name of feature + */ + def isDisabled = { attrs, body -> + return !isEnabled(attrs, body) + } + /** + * Render body if the feature is disabled + * @attr name REQUIRED name of feature + */ + def disabled = { attrs, body -> + if (isDisabled(attrs, body)) { + out << body() + } + } +} diff --git a/rundeckapp/test/unit/rundeck/services/ConfigurationServiceSpec.groovy b/rundeckapp/test/unit/rundeck/services/ConfigurationServiceSpec.groovy index e9127c029e7..b1b66a604e0 100644 --- a/rundeckapp/test/unit/rundeck/services/ConfigurationServiceSpec.groovy +++ b/rundeckapp/test/unit/rundeck/services/ConfigurationServiceSpec.groovy @@ -105,4 +105,19 @@ class ConfigurationServiceSpec extends Specification { true | true false | false } + + void "set boolean"() { + given: + grailsApplication.config.clear() + when: + service.setBoolean('something.value', tval) + then: + service.getBoolean('something.value', false) == tval + grailsApplication.config.rundeck.something.value == tval + + where: + tval | _ + true | _ + false | _ + } } diff --git a/rundeckapp/test/unit/rundeck/services/feature/FeatureServiceSpec.groovy b/rundeckapp/test/unit/rundeck/services/feature/FeatureServiceSpec.groovy new file mode 100644 index 00000000000..ff94dace086 --- /dev/null +++ b/rundeckapp/test/unit/rundeck/services/feature/FeatureServiceSpec.groovy @@ -0,0 +1,83 @@ +package rundeck.services.feature + +import grails.test.mixin.TestFor +import rundeck.services.ConfigurationService +import spock.lang.Specification +import spock.lang.Unroll + +/** + * See the API for {@link grails.test.mixin.services.ServiceUnitTestMixin} for usage instructions + */ +@TestFor(FeatureService) +@Unroll +class FeatureServiceSpec extends Specification { + + def setup() { + } + + def cleanup() { + } + + void "feature enabled via config"() { + given: + service.configurationService = Mock(ConfigurationService) + + when: + def result = service.featurePresent('afeature') + + then: + result == ispresent + 1 * service.configurationService.getBoolean('feature.*.enabled', false) >> false + 1 * service.configurationService.getBoolean('feature.afeature.enabled', false) >> ispresent + + where: + ispresent | _ + true | _ + false | _ + } + void "feature enabled via splat"() { + given: + service.configurationService = Mock(ConfigurationService) + + when: + def result = service.featurePresent('afeature') + + then: + result == ispresent + 1 * service.configurationService.getBoolean('feature.*.enabled', false) >> ispresent + if(!ispresent) { + 1 * service.configurationService.getBoolean('feature.afeature.enabled', false) >> false + } + + where: + ispresent | _ + true | _ + false | _ + } + void "set feature to #ispresent"() { + given: + service.configurationService = Mock(ConfigurationService) + + when: + service.toggleFeature('afeature', ispresent) + + then: + 1 * service.configurationService.setBoolean('feature.afeature.enabled', ispresent) + + where: + ispresent | _ + true | _ + false | _ + } + void "get feature config"() { + given: + service.configurationService = Mock(ConfigurationService) + + when: + service.getFeatureConfig('afeature') + + then: + 1 * service.configurationService.getConfig('feature.afeature.config') + + } +} From bd3203a102e2a74e056362226c7fb11e56bdb762 Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Thu, 30 Jun 2016 12:58:41 -0700 Subject: [PATCH 06/11] enable all features in dev mode --- rundeckapp/grails-app/conf/Config.groovy | 1 + 1 file changed, 1 insertion(+) diff --git a/rundeckapp/grails-app/conf/Config.groovy b/rundeckapp/grails-app/conf/Config.groovy index 6fb7bfba5bc..33bff36e46c 100644 --- a/rundeckapp/grails-app/conf/Config.groovy +++ b/rundeckapp/grails-app/conf/Config.groovy @@ -132,6 +132,7 @@ log4j={ environments{ development{ feature.incubator.'*'=true + rundeck.feature.'*'.enabled=true } production{ //disable feature toggling From f48db2bfffaf46b99a41440520268ce7b1cac74a Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Thu, 30 Jun 2016 12:59:42 -0700 Subject: [PATCH 07/11] feature toggle for new dynamic step info in execution Disable with: rundeck.feature.workflowDynamicStepSummaryGUI.enabled=false --- .../assets/javascripts/executionStateKO.js | 11 ++++--- rundeckapp/grails-app/conf/Config.groovy | 3 ++ .../execution/_nodeCurrentStateSimpleKO.gsp | 7 ++++- .../execution/_wfstateNodeModelDisplay.gsp | 30 ++++++++++++++----- .../grails-app/views/execution/show.gsp | 2 +- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/rundeckapp/grails-app/assets/javascripts/executionStateKO.js b/rundeckapp/grails-app/assets/javascripts/executionStateKO.js index c0633e381d4..22fcf492689 100644 --- a/rundeckapp/grails-app/assets/javascripts/executionStateKO.js +++ b/rundeckapp/grails-app/assets/javascripts/executionStateKO.js @@ -541,10 +541,13 @@ function MultiWorkflow(workflowInfo,data){ self.getStepInfoForStepctx=function(stepctx,callback){ "use strict"; if(self.dynamicStepDescriptionDisabled){ - return { - type:self.workflowInfo.contextType(stepctx), - stepident:self.workflowInfo.renderContextString(stepctx) - }; + if(!self.stepinfoset[stepctx]) { + self.stepinfoset[stepctx] = new WorkflowStepInfo(self, stepctx, { + type: self.workflowInfo.contextType(stepctx), + stepident: self.workflowInfo.renderContextString(stepctx) + }); + } + return self.stepinfoset[stepctx]; } if(self.stepinfoset[stepctx]){ diff --git a/rundeckapp/grails-app/conf/Config.groovy b/rundeckapp/grails-app/conf/Config.groovy index 33bff36e46c..0ec42e9e5ad 100644 --- a/rundeckapp/grails-app/conf/Config.groovy +++ b/rundeckapp/grails-app/conf/Config.groovy @@ -139,6 +139,9 @@ environments{ feature.incubator.feature = false //enable takeover schedule feature feature.incubator.jobs = true + + //enable dynamic workflow step descriptions in GUI by default + rundeck.feature.workflowDynamicStepSummaryGUI.enabled = true } } diff --git a/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp b/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp index 55ec7097551..9542433e48c 100644 --- a/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp +++ b/rundeckapp/grails-app/views/execution/_nodeCurrentStateSimpleKO.gsp @@ -22,6 +22,11 @@ - + + + + + + diff --git a/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp b/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp index 6715267303f..fcee76fa3b1 100644 --- a/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp +++ b/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp @@ -97,7 +97,14 @@
- + + + + + + + + (Next up) @@ -129,16 +136,23 @@ "> - - %{----}% + + + + + + + %{----}% - %{----}% - %{----}% + %{----}% + %{----}% %{----}% - %{----}% - + %{----}% + + + + -
diff --git a/rundeckapp/grails-app/views/execution/show.gsp b/rundeckapp/grails-app/views/execution/show.gsp index 9ed2f696433..781f56b25c4 100644 --- a/rundeckapp/grails-app/views/execution/show.gsp +++ b/rundeckapp/grails-app/views/execution/show.gsp @@ -66,7 +66,7 @@ workflow = new RDWorkflow(workflowData); var multiworkflow=new MultiWorkflow(workflow,{ - dynamicStepDescriptionDisabled:false, + dynamicStepDescriptionDisabled:${enc(js:feature.isDisabled(name:'workflowDynamicStepSummaryGUI'))}, url:appLinks.scheduledExecutionWorkflowJson, id:execInfo.jobId||execInfo.execId,//id of job or execution workflow:execInfo.jobId?null:workflowData From df057c09ad2f5f28dcd0084db31f31e796de9b28 Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Thu, 30 Jun 2016 12:59:57 -0700 Subject: [PATCH 08/11] wrap gsp template with a knockout template --- .../views/execution/_wfstateSummaryDisplay.gsp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/rundeckapp/grails-app/views/execution/_wfstateSummaryDisplay.gsp b/rundeckapp/grails-app/views/execution/_wfstateSummaryDisplay.gsp index 6fca8d3b2ce..4d0c412edfa 100644 --- a/rundeckapp/grails-app/views/execution/_wfstateSummaryDisplay.gsp +++ b/rundeckapp/grails-app/views/execution/_wfstateSummaryDisplay.gsp @@ -14,6 +14,9 @@ limitations under the License. --}%
+
@@ -118,8 +121,7 @@
-
- +
@@ -134,8 +136,7 @@
-
- +
@@ -152,8 +153,7 @@ %{--display up to 5 partial nodes nodes--}%
-
- +
From d9dfec106b2bbc47b124989de93e28e807c55a62 Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Thu, 30 Jun 2016 14:53:57 -0700 Subject: [PATCH 09/11] fix: tooltip should disappear when dom element is removed --- .../grails-app/assets/javascripts/ko/binding-popover.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rundeckapp/grails-app/assets/javascripts/ko/binding-popover.js b/rundeckapp/grails-app/assets/javascripts/ko/binding-popover.js index fe22a47c905..c9762fe1b0c 100644 --- a/rundeckapp/grails-app/assets/javascripts/ko/binding-popover.js +++ b/rundeckapp/grails-app/assets/javascripts/ko/binding-popover.js @@ -25,6 +25,10 @@ ko.bindingHandlers.bootstrapTooltip = { init: function (element, valueAccessor, allBindings, viewModel, bindingContext) { "use strict"; jQuery(element).tooltip({}); + + ko.utils.domNodeDisposal.addDisposeCallback(element, function() { + jQuery(element).tooltip("destroy"); + }); }, update: function (element, valueAccessor, allBindings, viewModel, bindingContext) { "use strict"; From e94de4b98756ffe480afa21e12149ee9e193206a Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Thu, 30 Jun 2016 14:54:34 -0700 Subject: [PATCH 10/11] show full step path when viewing output --- .../grails-app/views/execution/_wfstateNodeModelDisplay.gsp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp b/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp index fcee76fa3b1..ac155143a8b 100644 --- a/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp +++ b/rundeckapp/grails-app/views/execution/_wfstateNodeModelDisplay.gsp @@ -141,7 +141,7 @@ - + %{----}% %{----}% From 13d64926a8eef79016dd68fe5f2c88292bda00b2 Mon Sep 17 00:00:00 2001 From: Greg Schueler Date: Thu, 30 Jun 2016 14:55:47 -0700 Subject: [PATCH 11/11] Show message when output log is empty --- rundeckapp/grails-app/i18n/messages.properties | 1 + rundeckapp/grails-app/i18n/messages_es_419.properties | 3 ++- .../grails-app/views/execution/_showFragment.gsp | 11 +++++++++++ rundeckapp/web-app/js/executionControl.js | 6 +++++- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/rundeckapp/grails-app/i18n/messages.properties b/rundeckapp/grails-app/i18n/messages.properties index 14349e25b0d..d071999f23f 100644 --- a/rundeckapp/grails-app/i18n/messages.properties +++ b/rundeckapp/grails-app/i18n/messages.properties @@ -1301,3 +1301,4 @@ edit.this.option=Edit this option delete.this.option=Delete this option edit=edit really.delete.option.0=Really delete option {0}? +execution.log.no.output=Execution has no log output diff --git a/rundeckapp/grails-app/i18n/messages_es_419.properties b/rundeckapp/grails-app/i18n/messages_es_419.properties index 0f44abe33ad..cdc23820067 100644 --- a/rundeckapp/grails-app/i18n/messages_es_419.properties +++ b/rundeckapp/grails-app/i18n/messages_es_419.properties @@ -1300,4 +1300,5 @@ move.down=Move down edit.this.option=Edit this option delete.this.option=Delete this option edit=edit -really.delete.option.0=Really delete option {0}? \ No newline at end of file +really.delete.option.0=Really delete option {0}? +execution.log.no.output=Execution has no log output diff --git a/rundeckapp/grails-app/views/execution/_showFragment.gsp b/rundeckapp/grails-app/views/execution/_showFragment.gsp index 5f7a4c2e0f7..d841d902790 100644 --- a/rundeckapp/grails-app/views/execution/_showFragment.gsp +++ b/rundeckapp/grails-app/views/execution/_showFragment.gsp @@ -226,4 +226,15 @@
+
diff --git a/rundeckapp/web-app/js/executionControl.js b/rundeckapp/web-app/js/executionControl.js index 3e021e6c5cd..8fd5b75b76c 100644 --- a/rundeckapp/web-app/js/executionControl.js +++ b/rundeckapp/web-app/js/executionControl.js @@ -727,7 +727,7 @@ var FollowControl = Class.create({ } if (this.runningcmd.jobcompleted && !this.runningcmd.completed) { this.jobFinishStatus(this.runningcmd.jobstatus,this.runningcmd.statusString); - var message=null + var message=null; var percent=null; if(this.runningcmd.percent!=null){ percent= Math.ceil(this.runningcmd.percent); @@ -803,6 +803,10 @@ var FollowControl = Class.create({ this.appendCmdOutputError("finishDataOutput"+e); } } + if(this.lineCount == 0) { + //show empty message + jQuery('#' + this.parentElement+'_empty').show(); + } }, toggleDataBody: function(ctxid) { if (Element.visible('databody' + ctxid)) {