diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 666cf8c6bf..1b17291fba 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -3,6 +3,7 @@ # Python from io import StringIO +import datetime import codecs import json import logging @@ -1218,12 +1219,17 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique status_data['instance_group_name'] = self.instance_group.name else: status_data['instance_group_name'] = None + elif status in ['successful', 'failed', 'canceled'] and self.finished: + status_data['finished'] = datetime.datetime.strftime(self.finished, "%Y-%m-%dT%H:%M:%S.%fZ") status_data.update(self.websocket_emit_data()) status_data['group_name'] = 'jobs' + if getattr(self, 'unified_job_template_id', None): + status_data['unified_job_template_id'] = self.unified_job_template_id emit_channel_notification('jobs-status_changed', status_data) if self.spawned_by_workflow: status_data['group_name'] = "workflow_events" + status_data['workflow_job_template_id'] = self.unified_job_template.id emit_channel_notification('workflow_events-' + str(self.workflow_job_id), status_data) except IOError: # includes socket errors logger.exception('%s failed to emit channel msg about status change', self.log_format) diff --git a/awx/ui/client/features/jobs/jobsList.controller.js b/awx/ui/client/features/jobs/jobsList.controller.js index 2a7225de6a..5720a53b80 100644 --- a/awx/ui/client/features/jobs/jobsList.controller.js +++ b/awx/ui/client/features/jobs/jobsList.controller.js @@ -25,15 +25,15 @@ function ListJobsController ( vm.strings = strings; + let newJobs = []; + // smart-search const name = 'jobs'; const iterator = 'job'; let paginateQuerySet = {}; let launchModalOpen = false; - let refreshAfterLaunchClose = false; - let pendingRefresh = false; - let refreshTimerRunning = false; + let newJobsTimerRunning = false; vm.searchBasePath = SearchBasePath; @@ -104,23 +104,53 @@ function ListJobsController ( $scope.$emit('updateCount', vm.job_dataset.count, 'jobs'); }); - $scope.$on('ws-jobs', () => { - if (!launchModalOpen) { - if (!refreshTimerRunning) { - refreshJobs(); - } else { - pendingRefresh = true; + const canAddRowsDynamically = () => { + const orderByValue = _.get($state.params, 'job_search.order_by'); + const pageValue = _.get($state.params, 'job_search.page'); + const idInValue = _.get($state.params, 'job_search.id__in'); + + return (!idInValue && (!pageValue || pageValue === '1') + && (orderByValue === '-finished' || orderByValue === '-started')); + }; + + const updateJobRow = (msg) => { + // Loop across the jobs currently shown and update the row + // if it exists + for (let i = 0; i < vm.jobs.length; i++) { + if (vm.jobs[i].id === msg.unified_job_id) { + // Update the job status. + vm.jobs[i].status = msg.status; + if (msg.finished) { + vm.jobs[i].finished = msg.finished; + const orderByValue = _.get($state.params, 'job_search.order_by'); + if (orderByValue === '-finished') { + // Attempt to sort the rows in the list by their finish + // timestamp in descending order + vm.jobs.sort((a, b) => + (!b.finished) - (!a.finished) + || new Date(b.finished) - new Date(a.finished)); + } + } + break; } - } else { - refreshAfterLaunchClose = true; + } + }; + + $scope.$on('ws-jobs', (e, msg) => { + if (msg.status === 'pending' && canAddRowsDynamically()) { + newJobs.push(msg.unified_job_id); + if (!launchModalOpen && !newJobsTimerRunning) { + fetchNewJobs(); + } + } else if (!newJobs.includes(msg.unified_job_id)) { + updateJobRow(msg); } }); $scope.$on('launchModalOpen', (evt, isOpen) => { evt.stopPropagation(); - if (!isOpen && refreshAfterLaunchClose) { - refreshAfterLaunchClose = false; - refreshJobs(); + if (!isOpen && newJobs.length > 0) { + fetchNewJobs(); } launchModalOpen = isOpen; }); @@ -289,22 +319,49 @@ function ListJobsController ( }); }; - function refreshJobs () { - qs.search(SearchBasePath, $state.params.job_search, { 'X-WS-Session-Quiet': true }) + const fetchNewJobs = () => { + newJobsTimerRunning = true; + const newJobIdsFilter = newJobs.join(','); + newJobs = []; + const newJobsSearchParams = Object.assign({}, $state.params.job_search); + newJobsSearchParams.count_disabled = 1; + newJobsSearchParams.id__in = newJobIdsFilter; + delete newJobsSearchParams.page_size; + const stringifiedSearchParams = qs.encodeQueryset(newJobsSearchParams, false); + Rest.setUrl(`${vm.searchBasePath}${stringifiedSearchParams}`); + Rest.get() .then(({ data }) => { - vm.jobs = data.results; - vm.job_dataset = data; + vm.job_dataset.count += data.results.length; + const pageSize = parseInt($state.params.job_search.page_size, 10) || 20; + const joinedJobs = data.results.concat(vm.jobs); + vm.jobs = joinedJobs.length > pageSize + ? joinedJobs.slice(0, pageSize) + : joinedJobs; + $timeout(() => { + if (canAddRowsDynamically()) { + if (newJobs.length > 0 && !launchModalOpen) { + fetchNewJobs(); + } else { + newJobsTimerRunning = false; + } + } else { + // Bail out - one of [order_by, page, id__in] params has changed since we + // received these new job messages + newJobs = []; + newJobsTimerRunning = false; + } + }, 5000); + }) + .catch(({ data, status }) => { + ProcessErrors($scope, data, status, null, { + hdr: strings.get('error.HEADER'), + msg: strings.get('error.CALL', { + path: `${vm.searchBasePath}${stringifiedSearchParams}`, + status + }) + }); }); - pendingRefresh = false; - refreshTimerRunning = true; - $timeout(() => { - if (pendingRefresh) { - refreshJobs(); - } else { - refreshTimerRunning = false; - } - }, 5000); - } + }; vm.isCollapsed = true; diff --git a/awx/ui/client/features/projects/projectsList.controller.js b/awx/ui/client/features/projects/projectsList.controller.js index f90597ac98..df06920f60 100644 --- a/awx/ui/client/features/projects/projectsList.controller.js +++ b/awx/ui/client/features/projects/projectsList.controller.js @@ -113,11 +113,6 @@ function projectsListController ( // And we found the affected project $log.debug(`Received event for project: ${project.name}`); $log.debug(`Status changed to: ${data.status}`); - if (data.status === 'successful' || data.status === 'failed' || data.status === 'canceled') { - reloadList(); - } else { - project.scm_update_tooltip = vm.strings.get('update.UPDATE_RUNNING'); - } project.status = data.status; buildTooltips(project); } diff --git a/awx/ui/client/features/templates/templatesList.controller.js b/awx/ui/client/features/templates/templatesList.controller.js index 8c95aa745a..2500a78816 100644 --- a/awx/ui/client/features/templates/templatesList.controller.js +++ b/awx/ui/client/features/templates/templatesList.controller.js @@ -24,7 +24,6 @@ function ListTemplatesController( qs, GetBasePath, ngToast, - $timeout ) { const vm = this || {}; const [jobTemplate, workflowTemplate] = resolvedModels; @@ -32,10 +31,6 @@ function ListTemplatesController( const choices = workflowTemplate.options('actions.GET.type.choices') .concat(jobTemplate.options('actions.GET.type.choices')); - let launchModalOpen = false; - let refreshAfterLaunchClose = false; - let pendingRefresh = false; - let refreshTimerRunning = false; let paginateQuerySet = {}; vm.strings = strings; @@ -120,25 +115,39 @@ function ListTemplatesController( setToolbarSort(); }, true); - $scope.$on(`ws-jobs`, () => { - if (!launchModalOpen) { - if (!refreshTimerRunning) { - refreshTemplates(); - } else { - pendingRefresh = true; - } - } else { - refreshAfterLaunchClose = true; - } - }); + $scope.$on(`ws-jobs`, (e, msg) => { + if (msg.unified_job_template_id && vm.templates) { + const template = vm.templates.find((t) => t.id === msg.unified_job_template_id); + if (template) { + if (msg.status === 'pending') { + // This is a new job - add it to the front of the + // recent_jobs array + if (template.summary_fields.recent_jobs.length === 10) { + template.summary_fields.recent_jobs.pop(); + } - $scope.$on('launchModalOpen', (evt, isOpen) => { - evt.stopPropagation(); - if (!isOpen && refreshAfterLaunchClose) { - refreshAfterLaunchClose = false; - refreshTemplates(); + template.summary_fields.recent_jobs.unshift({ + id: msg.unified_job_id, + status: msg.status, + type: msg.type + }); + } else { + // This is an update to an existing job. Check to see + // if we have it in our array of recent_jobs + for (let i=0; i { @@ -265,15 +274,6 @@ function ListTemplatesController( vm.templates = vm.dataset.results; }) .finally(() => Wait('stop')); - pendingRefresh = false; - refreshTimerRunning = true; - $timeout(() => { - if (pendingRefresh) { - refreshTemplates(); - } else { - refreshTimerRunning = false; - } - }, 5000); } function createErrorHandler(path, action) { @@ -483,8 +483,7 @@ ListTemplatesController.$inject = [ 'Wait', 'QuerySet', 'GetBasePath', - 'ngToast', - '$timeout' + 'ngToast' ]; export default ListTemplatesController; diff --git a/awx/ui/client/src/home/dashboard/lists/job-templates/job-templates-list.directive.js b/awx/ui/client/src/home/dashboard/lists/job-templates/job-templates-list.directive.js index 1673d56d53..b1ba95a73d 100644 --- a/awx/ui/client/src/home/dashboard/lists/job-templates/job-templates-list.directive.js +++ b/awx/ui/client/src/home/dashboard/lists/job-templates/job-templates-list.directive.js @@ -11,7 +11,7 @@ export default templateUrl: templateUrl('home/dashboard/lists/job-templates/job-templates-list') }; - function link(scope, element, attr) { + function link(scope) { scope.$watch("data", function(data) { if (data) { @@ -22,7 +22,7 @@ export default scope.noJobTemplates = true; } } - }); + }, true); scope.canAddJobTemplate = false; let url = GetBasePath('job_templates'); diff --git a/awx/ui/client/src/home/home.controller.js b/awx/ui/client/src/home/home.controller.js index f964d9d7ef..cef2324fd0 100644 --- a/awx/ui/client/src/home/home.controller.js +++ b/awx/ui/client/src/home/home.controller.js @@ -12,18 +12,152 @@ export default ['$scope','Wait', '$timeout', 'i18n', var dataCount = 0; let launchModalOpen = false; let refreshAfterLaunchClose = false; - let pendingRefresh = false; - let refreshTimerRunning = false; + let pendingDashboardRefresh = false; + let dashboardTimerRunning = false; + let newJobsTimerRunning = false; + let newTemplatesTimerRunning = false; + let newJobs = []; + let newTemplates =[]; - $scope.$on('ws-jobs', function () { - if (!launchModalOpen) { - if (!refreshTimerRunning) { - refreshLists(); + const fetchDashboardData = () => { + Rest.setUrl(GetBasePath('dashboard')); + Rest.get() + .then(({data}) => { + $scope.dashboardData = data; + }) + .catch(({data, status}) => { + ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard host graph data: ${status}`) }); + }); + + if ($scope.graphData) { + Rest.setUrl(`${GetBasePath('dashboard')}graphs/jobs/?period=${$scope.graphData.period}&job_type=${$scope.graphData.jobType}`); + Rest.setHeader({'X-WS-Session-Quiet': true}); + Rest.get() + .then(function(value) { + if($scope.graphData.status === "successful" || $scope.graphData.status === "failed"){ + delete value.data.jobs[$scope.graphData.status]; + } + $scope.graphData.jobStatus = value.data; + }) + .catch(function({data, status}) { + ProcessErrors(null, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard graph data: ${status}`)}); + }); + } + + pendingDashboardRefresh = false; + dashboardTimerRunning = true; + $timeout(() => { + if (pendingDashboardRefresh) { + fetchDashboardData(); } else { - pendingRefresh = true; + dashboardTimerRunning = false; + } + }, 5000); + }; + + const fetchNewJobs = () => { + newJobsTimerRunning = true; + const newJobIdsFilter = newJobs.join(','); + newJobs = []; + Rest.setUrl(`${GetBasePath("unified_jobs")}?id__in=${newJobIdsFilter}&order_by=-finished&finished__isnull=false&type=workflow_job,job&count_disabled=1`); + Rest.get() + .then(({ data }) => { + const joinedJobs = data.results.concat($scope.dashboardJobsListData); + $scope.dashboardJobsListData = + joinedJobs.length > 5 ? joinedJobs.slice(0, 5) : joinedJobs; + $timeout(() => { + if (newJobs.length > 0) { + fetchNewJobs(); + } else { + newJobsTimerRunning = false; + } + }, 5000); + }) + .catch(({ data, status }) => { + ProcessErrors($scope, data, status, null, { + hdr: i18n._('Error!'), + msg: i18n._(`Failed to get new jobs for dashboard: ${status}`) + }); + }); + }; + + const fetchNewTemplates = () => { + newTemplatesTimerRunning = true; + const newTemplateIdsFilter = newTemplates.join(','); + newTemplates = []; + Rest.setUrl(`${GetBasePath("unified_job_templates")}?id__in=${newTemplateIdsFilter}&order_by=-last_job_run&last_job_run__isnull=false&type=workflow_job_template,job_template&count_disabled=1"`); + Rest.get() + .then(({ data }) => { + const joinedTemplates = data.results.concat($scope.dashboardJobTemplatesListData).sort((a, b) => new Date(b.last_job_run) - new Date(a.last_job_run)); + $scope.dashboardJobTemplatesListData = + joinedTemplates.length > 5 ? joinedTemplates.slice(0, 5) : joinedTemplates; + $timeout(() => { + if (newTemplates.length > 0 && !launchModalOpen) { + fetchNewTemplates(); + } else { + newTemplatesTimerRunning = false; + } + }, 5000); + }) + .catch(({ data, status }) => { + ProcessErrors($scope, data, status, null, { + hdr: i18n._('Error!'), + msg: i18n._(`Failed to get new templates for dashboard: ${status}`) + }); + }); + }; + + $scope.$on('ws-jobs', function (e, msg) { + if (msg.status === 'successful' || msg.status === 'failed' || msg.status === 'canceled' || msg.status === 'error') { + newJobs.push(msg.unified_job_id); + if (!newJobsTimerRunning) { + fetchNewJobs(); + } + if (!launchModalOpen) { + if (!dashboardTimerRunning) { + fetchDashboardData(); + } else { + pendingDashboardRefresh = true; + } + } else { + refreshAfterLaunchClose = true; + } + } + + const template = $scope.dashboardJobTemplatesListData.find((t) => t.id === msg.unified_job_template_id); + if (template) { + if (msg.status === 'pending') { + if (template.summary_fields.recent_jobs.length === 10) { + template.summary_fields.recent_jobs.pop(); + } + + template.summary_fields.recent_jobs.unshift({ + id: msg.unified_job_id, + status: msg.status, + type: msg.type + }); + } else { + for (let i=0; i new Date(b.last_job_run) - new Date(a.last_job_run)); + } } } else { - refreshAfterLaunchClose = true; + newTemplates.push(msg.unified_job_template_id); + if (!launchModalOpen && !newTemplatesTimerRunning) { + fetchNewTemplates(); + } } }); @@ -31,7 +165,10 @@ export default ['$scope','Wait', '$timeout', 'i18n', evt.stopPropagation(); if (!isOpen && refreshAfterLaunchClose) { refreshAfterLaunchClose = false; - refreshLists(); + fetchDashboardData(); + if (newTemplates.length > 0) { + fetchNewTemplates(); + } } launchModalOpen = isOpen; }); @@ -75,61 +212,6 @@ export default ['$scope','Wait', '$timeout', 'i18n', $scope.$emit('dashboardDataLoadComplete'); }); - function refreshLists () { - Rest.setUrl(GetBasePath('dashboard')); - Rest.get() - .then(({data}) => { - $scope.dashboardData = data; - }) - .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard host graph data: ${status}`) }); - }); - - Rest.setUrl(GetBasePath("unified_jobs") + "?order_by=-finished&page_size=5&finished__isnull=false&type=workflow_job,job&count_disabled=1"); - Rest.setHeader({'X-WS-Session-Quiet': true}); - Rest.get() - .then(({data}) => { - $scope.dashboardJobsListData = data.results; - }) - .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard jobs list: ${status}`) }); - }); - - Rest.setUrl(GetBasePath("unified_job_templates") + "?order_by=-last_job_run&page_size=5&last_job_run__isnull=false&type=workflow_job_template,job_template&count_disabled=1"); - Rest.get() - .then(({data}) => { - $scope.dashboardJobTemplatesListData = data.results; - }) - .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard jobs list: ${status}`) }); - }); - - if ($scope.graphData) { - Rest.setUrl(`${GetBasePath('dashboard')}graphs/jobs/?period=${$scope.graphData.period}&job_type=${$scope.graphData.jobType}`); - Rest.setHeader({'X-WS-Session-Quiet': true}); - Rest.get() - .then(function(value) { - if($scope.graphData.status === "successful" || $scope.graphData.status === "failed"){ - delete value.data.jobs[$scope.graphData.status]; - } - $scope.graphData.jobStatus = value.data; - }) - .catch(function({data, status}) { - ProcessErrors(null, data, status, null, { hdr: i18n._('Error!'), msg: i18n._(`Failed to get dashboard graph data: ${status}`)}); - }); - } - - pendingRefresh = false; - refreshTimerRunning = true; - $timeout(() => { - if (pendingRefresh) { - refreshLists(); - } else { - refreshTimerRunning = false; - } - }, 5000); - } - Wait('start'); Rest.setUrl(GetBasePath('dashboard')); Rest.get() diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/sources/list/sources-list.controller.js b/awx/ui/client/src/inventories-hosts/inventories/related/sources/list/sources-list.controller.js index e1ba3dfd75..5efa694624 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/sources/list/sources-list.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/sources/list/sources-list.controller.js @@ -42,21 +42,7 @@ $scope.$on(`ws-jobs`, function(e, data){ inventory_source = Find({ list: $scope.inventory_sources, key: 'id', val: data.inventory_source_id }); - if (inventory_source === undefined || inventory_source === null) { - inventory_source = {}; - } - - if(data.status === 'failed' || data.status === 'successful'){ - let path = GetBasePath('inventory') + $stateParams.inventory_id + '/inventory_sources'; - - qs.search(path, $state.params[`${list.iterator}_search`]) - .then((searchResponse)=> { - $scope[`${list.iterator}_dataset`] = searchResponse.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - _.forEach($scope[list.name], buildStatusIndicators); - optionsRequestDataProcessing(); - }); - } else { + if (inventory_source) { var status = GetSyncStatusMsg({ status: data.status }); diff --git a/awx/ui/client/src/organizations/linkout/controllers/organizations-job-templates.controller.js b/awx/ui/client/src/organizations/linkout/controllers/organizations-job-templates.controller.js index d01049ed21..5d8cacb915 100644 --- a/awx/ui/client/src/organizations/linkout/controllers/organizations-job-templates.controller.js +++ b/awx/ui/client/src/organizations/linkout/controllers/organizations-job-templates.controller.js @@ -4,25 +4,45 @@ * All Rights Reserved *************************************************/ -export default ['$scope', '$rootScope', - '$stateParams', 'Rest', 'ProcessErrors', - 'GetBasePath', 'Wait', - '$state', 'OrgJobTemplateList', 'OrgJobTemplateDataset', 'QuerySet', - function($scope, $rootScope, - $stateParams, Rest, ProcessErrors, - GetBasePath, Wait, - $state, OrgJobTemplateList, Dataset, qs) { +export default ['$scope', '$stateParams', 'Rest', 'GetBasePath', '$state', 'OrgJobTemplateList', 'OrgJobTemplateDataset', + function($scope, $stateParams, Rest, GetBasePath, $state, OrgJobTemplateList, Dataset) { var list = OrgJobTemplateList, orgBase = GetBasePath('organizations'); - $scope.$on(`ws-jobs`, function () { - let path = GetBasePath(list.basePath) || GetBasePath(list.name); - qs.search(path, $state.params[`${list.iterator}_search`]) - .then(function(searchResponse) { - $scope[`${list.iterator}_dataset`] = searchResponse.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - }); + $scope.$on(`ws-jobs`, function (e, msg) { + if (msg.unified_job_template_id && $scope[list.name]) { + const template = $scope[list.name].find((t) => t.id === msg.unified_job_template_id); + if (template) { + if (msg.status === 'pending') { + // This is a new job - add it to the front of the + // recent_jobs array + if (template.summary_fields.recent_jobs.length === 10) { + template.summary_fields.recent_jobs.pop(); + } + + template.summary_fields.recent_jobs.unshift({ + id: msg.unified_job_id, + status: msg.status, + type: msg.type + }); + } else { + // This is an update to an existing job. Check to see + // if we have it in our array of recent_jobs + for (let i=0; i