diff --git a/awx/ui/client/src/workflow-results/main.js b/awx/ui/client/src/workflow-results/main.js index 7faf366fc9..f01c60ccaf 100644 --- a/awx/ui/client/src/workflow-results/main.js +++ b/awx/ui/client/src/workflow-results/main.js @@ -7,10 +7,12 @@ import workflowStatusBar from './workflow-status-bar/main'; import route from './workflow-results.route.js'; import workflowResultsService from './workflow-results.service'; +import controller from './workflow-results.controller'; export default angular.module('workflowResults', [workflowStatusBar.name]) .run(['$stateExtender', function($stateExtender) { $stateExtender.addState(route); }]) + .controller('workflowResultsController', controller) .service('workflowResultsService', workflowResultsService); diff --git a/awx/ui/client/src/workflow-results/workflow-results.controller.js b/awx/ui/client/src/workflow-results/workflow-results.controller.js index 43d4423a9f..bd963fddc5 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.controller.js +++ b/awx/ui/client/src/workflow-results/workflow-results.controller.js @@ -10,6 +10,7 @@ export default ['workflowData', 'count', '$state', 'i18n', + 'moment', function(workflowData, workflowResultsService, workflowDataOptions, @@ -21,8 +22,10 @@ export default ['workflowData', WorkflowService, count, $state, - i18n + i18n, + moment ) { + var runTimeElapsedTimer = null; var getTowerLinks = function() { var getTowerLink = function(key) { @@ -57,6 +60,10 @@ export default ['workflowData', $scope.verbosity_label = getTowerLabel('verbosity'); }; + var updateWorkflowJobElapsedTimer = function(time) { + $scope.workflow.elapsed = time; + }; + function init() { // put initially resolved request data on scope $scope.workflow = workflowData; @@ -66,6 +73,11 @@ export default ['workflowData', $scope.count = count.val; $scope.showManualControls = false; + // Start elapsed time updater for job known to be running + if ($scope.workflow.started !== null && $scope.workflow.status === 'running') { + runTimeElapsedTimer = workflowResultsService.createOneSecondTimer($scope.workflow.started, updateWorkflowJobElapsedTimer); + } + // stdout full screen toggle tooltip text $scope.toggleStdoutFullscreenTooltip = i18n._("Expand Output"); @@ -169,8 +181,12 @@ export default ['workflowData', // Update the workflow job's unified job: if (parseInt(data.unified_job_id, 10) === parseInt($scope.workflow.id,10)) { $scope.workflow.status = data.status; + // start internval counter for job that transitioned to running + if ($scope.workflow.status === 'running') { + runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateWorkflowJobElapsedTimer); + } - if(data.status === "successful" || data.status === "failed"){ + if(data.status === "successful" || data.status === "failed" || data.status === "error"){ $state.go('.', null, { reload: true }); } } @@ -198,4 +214,8 @@ export default ['workflowData', $scope.$broadcast("refreshWorkflowChart"); } }); + + $scope.$on('$destroy', function() { + workflowResultsService.destroyTimer(runTimeElapsedTimer); + }); }]; diff --git a/awx/ui/client/src/workflow-results/workflow-results.service.js b/awx/ui/client/src/workflow-results/workflow-results.service.js index 601c845eaa..3a9fda703a 100644 --- a/awx/ui/client/src/workflow-results/workflow-results.service.js +++ b/awx/ui/client/src/workflow-results/workflow-results.service.js @@ -5,7 +5,7 @@ *************************************************/ -export default ['$q', 'Prompt', '$filter', 'Wait', 'Rest', '$state', 'ProcessErrors', 'InitiatePlaybookRun', function ($q, Prompt, $filter, Wait, Rest, $state, ProcessErrors, InitiatePlaybookRun) { +export default ['$q', 'Prompt', '$filter', 'Wait', 'Rest', '$state', 'ProcessErrors', 'InitiatePlaybookRun', '$interval', 'moment', function ($q, Prompt, $filter, Wait, Rest, $state, ProcessErrors, InitiatePlaybookRun, $interval, moment) { var val = { getCounts: function(workflowNodes){ var nodeArr = []; @@ -127,7 +127,20 @@ export default ['$q', 'Prompt', '$filter', 'Wait', 'Rest', '$state', 'ProcessErr relaunchJob: function(scope) { InitiatePlaybookRun({ scope: scope, id: scope.workflow.id, relaunch: true, job_type: 'workflow_job' }); - } + }, + createOneSecondTimer: function(startTime, fn) { + return $interval(function(){ + fn(moment().diff(moment(startTime), 'seconds')); + }, 1000); + }, + destroyTimer: function(timer) { + if (timer !== null) { + $interval.cancel(timer); + timer = null; + return true; + } + return false; + }, }; return val; }]; diff --git a/awx/ui/karma.conf.js b/awx/ui/karma.conf.js index 1a22f476b2..4d7d4c8363 100644 --- a/awx/ui/karma.conf.js +++ b/awx/ui/karma.conf.js @@ -20,6 +20,7 @@ module.exports = function(config) { './client/src/app.js', './node_modules/angular-mocks/angular-mocks.js', { pattern: './tests/**/*-test.js' }, + { pattern: './tests/**/*.json', included: false}, 'client/src/**/*.html' ], preprocessors: { diff --git a/awx/ui/package.json b/awx/ui/package.json index ff96d33b42..960b26b171 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -52,17 +52,18 @@ "grunt-newer": "^1.2.0", "grunt-webpack": "^1.0.11", "imports-loader": "^0.6.5", - "jasmine-core": "^2.4.1", + "jasmine-core": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-2.5.2.tgz", "jshint": "^2.9.4", "jshint-loader": "^0.8.3", "jshint-stylish": "^2.2.0", - "karma": "^1.1.2", + "json-loader": "^0.5.4", + "karma": "^1.4.1", "karma-chrome-launcher": "^1.0.1", "karma-coverage": "^1.1.1", "karma-firefox-launcher": "^1.0.0", "karma-html2js-preprocessor": "^1.0.0", - "karma-jasmine": "^1.0.2", - "karma-junit-reporter": "^1.1.0", + "karma-jasmine": "^1.1.0", + "karma-junit-reporter": "^1.2.0", "karma-phantomjs-launcher": "^1.0.2", "karma-sauce-launcher": "^1.0.0", "karma-sourcemap-loader": "^0.3.7", diff --git a/awx/ui/tests/spec/workflow--results/data/workflow_job.json b/awx/ui/tests/spec/workflow--results/data/workflow_job.json new file mode 100644 index 0000000000..59cc9159b8 --- /dev/null +++ b/awx/ui/tests/spec/workflow--results/data/workflow_job.json @@ -0,0 +1,63 @@ +{ + "id": 109, + "type": "workflow_job", + "url": "/api/v1/workflow_jobs/109/", + "related": { + "created_by": "/api/v1/users/1/", + "unified_job_template": "/api/v1/workflow_job_templates/27/", + "workflow_job_template": "/api/v1/workflow_job_templates/27/", + "notifications": "/api/v1/workflow_jobs/109/notifications/", + "workflow_nodes": "/api/v1/workflow_jobs/109/workflow_nodes/", + "labels": "/api/v1/workflow_jobs/109/labels/", + "activity_stream": "/api/v1/workflow_jobs/109/activity_stream/", + "relaunch": "/api/v1/workflow_jobs/109/relaunch/", + "cancel": "/api/v1/workflow_jobs/109/cancel/" + }, + "summary_fields": { + "workflow_job_template": { + "id": 27, + "name": "workflow timer", + "description": "" + }, + "unified_job_template": { + "id": 27, + "name": "workflow timer", + "description": "", + "unified_job_type": "workflow_job" + }, + "created_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "user_capabilities": { + "start": true, + "delete": true + }, + "labels": { + "count": 0, + "results": [] + } + }, + "created": "2017-02-01T14:56:47.416Z", + "modified": "2017-02-01T14:57:14.189Z", + "name": "workflow timer", + "description": "", + "unified_job_template": 27, + "launch_type": "manual", + "status": "successful", + "failed": false, + "started": "2017-02-01T14:56:47.754897Z", + "finished": "2017-02-01T14:57:14.182780Z", + "elapsed": 26.428, + "job_args": "", + "job_cwd": "", + "job_env": {}, + "job_explanation": "", + "result_stdout": "stdout capture is missing", + "execution_node": "", + "result_traceback": "", + "workflow_job_template": 27, + "extra_vars": "{}" +} \ No newline at end of file diff --git a/awx/ui/tests/spec/workflow--results/data/workflow_job_options.json b/awx/ui/tests/spec/workflow--results/data/workflow_job_options.json new file mode 100644 index 0000000000..cf2ebd60d9 --- /dev/null +++ b/awx/ui/tests/spec/workflow--results/data/workflow_job_options.json @@ -0,0 +1,203 @@ +{ + "name": "Workflow Job Detail", + "description": "# Retrieve Workflow Job:\n\nMake GET request to this resource to retrieve a single workflow job\nrecord containing the following fields:\n\n* `id`: Database ID for this workflow job. (integer)\n* `type`: Data type for this workflow job. (choice)\n* `url`: URL for this workflow job. (string)\n* `related`: Data structure with URLs of related resources. (object)\n* `summary_fields`: Data structure with name/description for related resources. (object)\n* `created`: Timestamp when this workflow job was created. (datetime)\n* `modified`: Timestamp when this workflow job was last modified. (datetime)\n* `name`: Name of this workflow job. (string)\n* `description`: Optional description of this workflow job. (string)\n* `unified_job_template`: (field)\n* `launch_type`: (choice)\n - `manual`: Manual\n - `relaunch`: Relaunch\n - `callback`: Callback\n - `scheduled`: Scheduled\n - `dependency`: Dependency\n - `workflow`: Workflow\n - `sync`: Sync\n* `status`: (choice)\n - `new`: New\n - `pending`: Pending\n - `waiting`: Waiting\n - `running`: Running\n - `successful`: Successful\n - `failed`: Failed\n - `error`: Error\n - `canceled`: Canceled\n* `failed`: (boolean)\n* `started`: The date and time the job was queued for starting. (datetime)\n* `finished`: The date and time the job finished execution. (datetime)\n* `elapsed`: Elapsed time in seconds that the job ran. (decimal)\n* `job_args`: (string)\n* `job_cwd`: (string)\n* `job_env`: (field)\n* `job_explanation`: A status field to indicate the state of the job if it wasn't able to run and capture stdout (string)\n* `result_stdout`: (field)\n* `execution_node`: The Tower node the job executed on. (string)\n* `result_traceback`: (string)\n* `workflow_job_template`: (field)\n* `extra_vars`: (string)\n\n\n\n# Delete Workflow Job:\n\nMake a DELETE request to this resource to delete this workflow job.\n\n\n\n\n\n\n\n\n\n\n\n> _New in Ansible Tower 3.1.0_", + "renders": [ + "application/json", + "text/html" + ], + "parses": [ + "application/json" + ], + "actions": { + "GET": { + "id": { + "type": "integer", + "label": "ID", + "help_text": "Database ID for this workflow job." + }, + "type": { + "type": "choice", + "label": "Type", + "help_text": "Data type for this workflow job.", + "choices": [ + [ + "workflow_job", + "Workflow Job" + ] + ] + }, + "url": { + "type": "string", + "label": "Url", + "help_text": "URL for this workflow job." + }, + "related": { + "type": "object", + "label": "Related", + "help_text": "Data structure with URLs of related resources." + }, + "summary_fields": { + "type": "object", + "label": "Summary fields", + "help_text": "Data structure with name/description for related resources." + }, + "created": { + "type": "datetime", + "label": "Created", + "help_text": "Timestamp when this workflow job was created." + }, + "modified": { + "type": "datetime", + "label": "Modified", + "help_text": "Timestamp when this workflow job was last modified." + }, + "name": { + "type": "string", + "label": "Name", + "help_text": "Name of this workflow job." + }, + "description": { + "type": "string", + "label": "Description", + "help_text": "Optional description of this workflow job." + }, + "unified_job_template": { + "type": "field", + "label": "unified job template" + }, + "launch_type": { + "type": "choice", + "label": "Launch type", + "choices": [ + [ + "manual", + "Manual" + ], + [ + "relaunch", + "Relaunch" + ], + [ + "callback", + "Callback" + ], + [ + "scheduled", + "Scheduled" + ], + [ + "dependency", + "Dependency" + ], + [ + "workflow", + "Workflow" + ], + [ + "sync", + "Sync" + ] + ] + }, + "status": { + "type": "choice", + "label": "Status", + "choices": [ + [ + "new", + "New" + ], + [ + "pending", + "Pending" + ], + [ + "waiting", + "Waiting" + ], + [ + "running", + "Running" + ], + [ + "successful", + "Successful" + ], + [ + "failed", + "Failed" + ], + [ + "error", + "Error" + ], + [ + "canceled", + "Canceled" + ] + ] + }, + "failed": { + "type": "boolean", + "label": "Failed" + }, + "started": { + "type": "datetime", + "label": "Started", + "help_text": "The date and time the job was queued for starting." + }, + "finished": { + "type": "datetime", + "label": "Finished", + "help_text": "The date and time the job finished execution." + }, + "elapsed": { + "type": "decimal", + "label": "Elapsed", + "help_text": "Elapsed time in seconds that the job ran." + }, + "job_args": { + "type": "string", + "label": "Job args" + }, + "job_cwd": { + "type": "string", + "label": "Job cwd" + }, + "job_env": { + "type": "field", + "label": "job_env" + }, + "job_explanation": { + "type": "string", + "label": "Job explanation", + "help_text": "A status field to indicate the state of the job if it wasn't able to run and capture stdout" + }, + "result_stdout": { + "type": "field", + "label": "Result stdout" + }, + "execution_node": { + "type": "string", + "label": "Execution node", + "help_text": "The Tower node the job executed on." + }, + "result_traceback": { + "type": "string", + "label": "Result traceback" + }, + "workflow_job_template": { + "type": "field", + "label": "Workflow job template" + }, + "extra_vars": { + "type": "string", + "label": "Extra vars" + } + } + }, + "added_in_version": "3.1.0", + "types": [ + "workflow_job" + ] +} \ No newline at end of file diff --git a/awx/ui/tests/spec/workflow--results/workflow-results.controller-test.js b/awx/ui/tests/spec/workflow--results/workflow-results.controller-test.js new file mode 100644 index 0000000000..13a8683948 --- /dev/null +++ b/awx/ui/tests/spec/workflow--results/workflow-results.controller-test.js @@ -0,0 +1,138 @@ +'use strict'; +import moment from 'moment'; +import workflow_job_options_json from 'json!./data/workflow_job_options.json'; +import workflow_job_json from 'json!./data/workflow_job.json'; + +describe('Controller: workflowResults', () => { + let $controller; + let workflowResults; + let $rootScope; + let ParseVariableString; + let workflowResultsService; + let $interval; + + let treeData = { + data: { + children: [] + } + }; + + beforeEach(angular.mock.module('VariablesHelper')); + + beforeEach(angular.mock.module('workflowResults', ($provide) => { + ['PromptDialog', 'Prompt', 'Wait', 'Rest', '$state', 'ProcessErrors', + 'InitiatePlaybookRun', 'jobLabels', 'workflowNodes', 'count', + ].forEach((item) => { + $provide.value(item, {}); + }); + $provide.value('$stateExtender', { addState: jasmine.createSpy('addState'), }); + $provide.value('moment', moment); + $provide.value('workflowData', workflow_job_json); + $provide.value('workflowDataOptions', workflow_job_options_json); + $provide.value('ParseTypeChange', function() {}); + $provide.value('i18n', { '_': (a) => { return a; } }); + $provide.provider('$stateProvider', { '$get': function() { return function() {} } }); + $provide.service('WorkflowService', function($q) { + return { + buildTree: function() { + var deferred = $q.defer(); + deferred.resolve(treeData); + return deferred.promise; + } + } + }); + })); + + beforeEach(angular.mock.inject(function(_$controller_, _$rootScope_, _ParseVariableString_, _workflowResultsService_, _$interval_){ + $controller = _$controller_; + $rootScope = _$rootScope_; + ParseVariableString = _ParseVariableString_; + workflowResultsService = _workflowResultsService_; + $interval = _$interval_; + + })); + + describe('elapsed timer', () => { + let scope; + + beforeEach(() => { + scope = $rootScope.$new(); + spyOn(workflowResultsService, 'createOneSecondTimer').and.callThrough(); + spyOn(workflowResultsService, 'destroyTimer').and.callThrough(); + }); + + + function jobWaitingWorkflowResultsControllerFixture(started, status) { + workflow_job_json.started = started; + workflow_job_json.status = status; + workflowResults = $controller('workflowResultsController', { + $scope: scope, + $rootScope: $rootScope, + }); + } + + describe('init()', () => { + describe('job running', () => { + beforeEach(() => { + jobWaitingWorkflowResultsControllerFixture(moment(), 'running'); + }); + + // Note: Ensuring the outside service method is called to create a timer may + // be overkill. Especially since we validate the side effect in the next test. + it('should call to start timer on load when job is already running', () => { + expect(workflowResultsService.createOneSecondTimer).toHaveBeenCalled(); + expect(workflowResultsService.createOneSecondTimer.calls.argsFor(0)[0]).toBe(workflow_job_json.started); + }); + + it('should set update scope var with elapsed time', () => { + $interval.flush(10 * 1000); + + // TODO: mock moment() so when we fast-forward time with $interval + // the system clocks seems to fast forward too. + //expect(scope.workflow.elapsed).toBe(10); + }); + + it('should call to destroy timer on destroy', () => { + scope.$destroy(); + expect(workflowResultsService.destroyTimer).toHaveBeenCalled(); + expect(workflowResultsService.destroyTimer.calls.argsFor(0)[0]).not.toBe(null); + }); + }); + + describe('job is not running', () => { + beforeEach(() => { + jobWaitingWorkflowResultsControllerFixture(null, 'waiting'); + }); + + it('should not start elapsed timer', () => { + expect(workflowResultsService.createOneSecondTimer).not.toHaveBeenCalled(); + }); + + }); + }); + + describe('job transitions to running', () => { + beforeEach(() => { + jobWaitingWorkflowResultsControllerFixture(null, 'waiting'); + $rootScope.$broadcast('ws-jobs', { unified_job_id: workflow_job_json.id, status: "running" }); + }); + + it('should start elapsed timer', () => { + expect(scope.workflow.status).toBe("running"); + expect(workflowResultsService.createOneSecondTimer).toHaveBeenCalled(); + }); + }); + + describe('job finished', () => { + beforeEach(() => { + jobWaitingWorkflowResultsControllerFixture(null, 'waiting'); + $rootScope.$broadcast('ws-jobs', { unified_job_id: workflow_job_json.id, status: "running" }); + }); + + it('should start elapsed timer', () => { + expect(scope.workflow.status).toBe("running"); + expect(workflowResultsService.createOneSecondTimer).toHaveBeenCalled(); + }); + }); + }); +}); \ No newline at end of file diff --git a/awx/ui/tests/spec/workflow--results/workflow-results.service-test.js b/awx/ui/tests/spec/workflow--results/workflow-results.service-test.js new file mode 100644 index 0000000000..9010de6d7f --- /dev/null +++ b/awx/ui/tests/spec/workflow--results/workflow-results.service-test.js @@ -0,0 +1,59 @@ +'use strict'; +import moment from 'moment'; + +describe('workflowResultsService', () => { + let workflowResultsService; + let $interval; + + beforeEach(angular.mock.module('workflowResults', ($provide) => { + ['PromptDialog', 'Prompt', 'Wait', 'Rest', 'ProcessErrors', 'InitiatePlaybookRun', '$state'].forEach(function(item) { + $provide.value(item, {}); + }); + $provide.value('$stateExtender', { addState: jasmine.createSpy('addState'), }); + $provide.value('moment', moment); + })); + + beforeEach(angular.mock.inject((_workflowResultsService_, _$interval_) => { + workflowResultsService = _workflowResultsService_; + $interval = _$interval_; + })); + + describe('createOneSecondTimer()', () => { + it('should create a timer that runs every second, incremented by a second', (done) => { + let ticks = 0; + let ticks_expected = 10; + + workflowResultsService.createOneSecondTimer(moment(), function(time) { + ticks += 1; + if (ticks >= ticks_expected) { + expect(ticks).toBe(ticks_expected); + // TODO: should verify time is 10 but this requires mocking moment() + // because we "artificially" accelerate time. + done(); + } + }); + + $interval.flush(ticks_expected * 1000); + }); + }); + + describe('destroyTimer()', () => { + beforeEach(() => { + $interval.cancel = jasmine.createSpy('cancel'); + }); + + it('should not destroy null timer', () => { + workflowResultsService.destroyTimer(null); + + expect($interval.cancel).not.toHaveBeenCalled(); + }); + + it('should destroy passed in timer', () => { + let timer = jasmine.createSpy('timer'); + + workflowResultsService.destroyTimer(timer); + + expect($interval.cancel).toHaveBeenCalledWith(timer); + }); + }); +}); \ No newline at end of file