diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e79f2e3ad0..c621481712 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1419,6 +1419,48 @@ class ProjectUpdateSerializer(UnifiedJobSerializer, ProjectOptionsSerializer): return res +class ProjectUpdateDetailSerializer(ProjectUpdateSerializer): + + host_status_counts = serializers.SerializerMethodField( + help_text=_('A count of hosts uniquely assigned to each status.'), + ) + playbook_counts = serializers.SerializerMethodField( + help_text=_('A count of all plays and tasks for the job run.'), + ) + + class Meta: + model = ProjectUpdate + fields = ('*', 'host_status_counts', 'playbook_counts',) + + def get_playbook_counts(self, obj): + task_count = obj.project_update_events.filter(event='playbook_on_task_start').count() + play_count = obj.project_update_events.filter(event='playbook_on_play_start').count() + + data = {'play_count': play_count, 'task_count': task_count} + + return data + + def get_host_status_counts(self, obj): + try: + event_data = obj.project_update_events.only('event_data').get(event='playbook_on_stats').event_data + except ProjectUpdateEvent.DoesNotExist: + event_data = {} + + host_status = {} + host_status_keys = ['skipped', 'ok', 'changed', 'failures', 'dark'] + + for key in host_status_keys: + for host in event_data.get(key, {}): + host_status[host] = key + + host_status_counts = defaultdict(lambda: 0) + + for value in host_status.values(): + host_status_counts[value] += 1 + + return host_status_counts + + class ProjectUpdateListSerializer(ProjectUpdateSerializer, UnifiedJobListSerializer): pass diff --git a/awx/api/views.py b/awx/api/views.py index a635263140..c6cb9cb0c0 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1418,7 +1418,7 @@ class ProjectUpdateList(ListAPIView): class ProjectUpdateDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): model = ProjectUpdate - serializer_class = ProjectUpdateSerializer + serializer_class = ProjectUpdateDetailSerializer class ProjectUpdateEventsList(SubListAPIView): diff --git a/awx/main/tests/unit/api/serializers/test_job_serializers.py b/awx/main/tests/unit/api/serializers/test_job_serializers.py index d3fd514ecc..5688a845ec 100644 --- a/awx/main/tests/unit/api/serializers/test_job_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_job_serializers.py @@ -11,12 +11,14 @@ from awx.api.serializers import ( JobDetailSerializer, JobSerializer, JobOptionsSerializer, + ProjectUpdateDetailSerializer, ) from awx.main.models import ( Label, Job, JobEvent, + ProjectUpdateEvent, ) @@ -142,10 +144,52 @@ class TestJobDetailSerializerGetHostStatusCountFields(object): assert host_status_counts == {'ok': 1, 'changed': 1, 'dark': 2} - def test_host_status_counts_is_empty_dict_without_stats_event(self, job, mocker): + def test_host_status_counts_is_empty_dict_without_stats_event(self, job): job.job_events = JobEvent.objects.none() serializer = JobDetailSerializer() host_status_counts = serializer.get_host_status_counts(job) assert host_status_counts == {} + + +class TestProjectUpdateDetailSerializerGetHostStatusCountFields(object): + + def test_hosts_are_counted_once(self, project_update, mocker): + mock_event = ProjectUpdateEvent(**{ + 'event': 'playbook_on_stats', + 'event_data': { + 'skipped': { + 'localhost': 2, + 'fiz': 1, + }, + 'ok': { + 'localhost': 1, + 'foo': 2, + }, + 'changed': { + 'localhost': 1, + 'bar': 3, + }, + 'dark': { + 'localhost': 2, + 'fiz': 2, + } + } + }) + + mock_qs = namedtuple('mock_qs', ['get'])(mocker.MagicMock(return_value=mock_event)) + project_update.project_update_events.only = mocker.MagicMock(return_value=mock_qs) + + serializer = ProjectUpdateDetailSerializer() + host_status_counts = serializer.get_host_status_counts(project_update) + + assert host_status_counts == {'ok': 1, 'changed': 1, 'dark': 2} + + def test_host_status_counts_is_empty_dict_without_stats_event(self, project_update): + project_update.project_update_events = ProjectUpdateEvent.objects.none() + + serializer = ProjectUpdateDetailSerializer() + host_status_counts = serializer.get_host_status_counts(project_update) + + assert host_status_counts == {} diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js index acd640f22f..a7a16c75c8 100644 --- a/awx/ui/client/features/output/api.events.service.js +++ b/awx/ui/client/features/output/api.events.service.js @@ -17,7 +17,7 @@ function JobEventsApiService ($http, $q) { this.state = { current: 0, count: 0 }; }; - this.fetch = () => this.getFirst().then(() => this); + this.fetch = () => this.getLast().then(() => this); this.getFirst = () => { const page = 1; diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 33f143ecd5..310a8d0f31 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -516,7 +516,7 @@ function getExtraVarsDetails () { } function getLabelDetails () { - const jobLabels = _.get(resource.model.get('related.labels'), 'results', []); + const jobLabels = _.get(resource.model.get('summary_fields.labels'), 'results', []); if (jobLabels.length < 1) { return null; diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index 8d772e037d..9f0d05df3c 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -26,19 +26,20 @@ const PAGE_LIMIT = 5; const PAGE_SIZE = 50; const ORDER_BY = 'counter'; const WS_PREFIX = 'ws'; +const API_ROOT = '/api/v2/'; function resolveResource ( $state, + $stateParams, Job, ProjectUpdate, AdHocCommand, SystemJob, WorkflowJob, InventoryUpdate, - $stateParams, qs, Wait, - eventsApi, + Events, ) { const { id, type, handleErrors } = $stateParams; const { job_event_search } = $stateParams; // eslint-disable-line camelcase @@ -46,24 +47,28 @@ function resolveResource ( const { name, key } = getWebSocketResource(type); let Resource; - let related = 'events'; + let related; switch (type) { case 'project': Resource = ProjectUpdate; + related = `project_updates/${id}/events/`; break; case 'playbook': Resource = Job; - related = 'job_events'; + related = `jobs/${id}/job_events/`; break; case 'command': Resource = AdHocCommand; + related = `ad_hoc_commands/${id}/events/`; break; case 'system': Resource = SystemJob; + related = `system_jobs/${id}/events/`; break; case 'inventory': Resource = InventoryUpdate; + related = `inventory_updates/${id}/events/`; break; // case 'workflow': // todo: integrate workflow chart components into this view @@ -89,30 +94,15 @@ function resolveResource ( Object.assign(config.params, query); } - let model; + Events.init(`${API_ROOT}${related}`, config.params); Wait('start'); - const resourcePromise = new Resource(['get', 'options'], [id, id]) - .then(job => { - const endpoint = `${job.get('url')}${related}/`; - eventsApi.init(endpoint, config.params); - - const promises = [job.getStats(), eventsApi.fetch()]; - - if (job.has('related.labels')) { - promises.push(job.extend('get', 'labels')); - } - - model = job; - return Promise.all(promises); - }) - .then(([stats, events]) => ({ + const promise = Promise.all([new Resource(['get', 'options'], [id, id]), Events.fetch()]) + .then(([model, events]) => ({ id, type, - stats, model, events, - related, ws: { events: `${WS_PREFIX}-${key}-${id}`, status: `${WS_PREFIX}-${name}`, @@ -125,13 +115,14 @@ function resolveResource ( })); if (!handleErrors) { - return resourcePromise + return promise .finally(() => Wait('stop')); } - return resourcePromise + return promise .catch(({ data, status }) => { qs.error(data, status); + return $state.go($state.current, $state.params, { reload: true }); }) .finally(() => Wait('stop')); @@ -218,13 +209,13 @@ function JobsRun ($stateRegistry, $filter, strings) { ], resource: [ '$state', + '$stateParams', 'JobModel', 'ProjectUpdateModel', 'AdHocCommandModel', 'SystemJobModel', 'WorkflowJobModel', 'InventoryUpdateModel', - '$stateParams', 'QuerySet', 'Wait', 'JobEventsApiService', diff --git a/awx/ui/client/features/output/stats.component.js b/awx/ui/client/features/output/stats.component.js index 7ce5e31af1..31285cfbf1 100644 --- a/awx/ui/client/features/output/stats.component.js +++ b/awx/ui/client/features/output/stats.component.js @@ -26,13 +26,13 @@ function JobStatsController (strings, { subscribe }) { strings.get('tooltips.COLLAPSE_OUTPUT') : strings.get('tooltips.EXPAND_OUTPUT'); - unsubscribe = subscribe(({ running, elapsed, counts, stats, hosts }) => { + unsubscribe = subscribe(({ running, elapsed, counts, hosts }) => { vm.plays = counts.plays; vm.tasks = counts.tasks; vm.hosts = counts.hosts; vm.elapsed = elapsed; vm.running = running; - vm.setHostStatusCounts(stats, hosts); + vm.setHostStatusCounts(hosts); }); }; @@ -40,7 +40,9 @@ function JobStatsController (strings, { subscribe }) { unsubscribe(); }; - vm.setHostStatusCounts = (stats, counts) => { + vm.setHostStatusCounts = counts => { + let statsAreAvailable; + Object.keys(counts).forEach(key => { const count = counts[key]; const statusBarElement = $(`.HostStatusBar-${key}`); @@ -48,9 +50,11 @@ function JobStatsController (strings, { subscribe }) { statusBarElement.css('flex', `${count} 0 auto`); vm.tooltips[key] = createStatsBarTooltip(key, count); + + if (count) statsAreAvailable = true; }); - vm.statsAreAvailable = stats; + vm.statsAreAvailable = statsAreAvailable; }; vm.toggleExpanded = () => { diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js index dfb7d40e04..d387f8db41 100644 --- a/awx/ui/client/features/output/status.service.js +++ b/awx/ui/client/features/output/status.service.js @@ -1,3 +1,4 @@ +/* eslint camelcase: 0 */ const JOB_START = 'playbook_on_start'; const JOB_END = 'playbook_on_stats'; const PLAY_START = 'playbook_on_play_start'; @@ -12,7 +13,7 @@ function JobStatusService (moment, message) { this.dispatch = () => message.dispatch('status', this.state); this.subscribe = listener => message.subscribe('status', listener); - this.init = ({ model, stats }) => { + this.init = ({ model }) => { this.created = model.get('created'); this.job = model.get('id'); this.jobType = model.get('type'); @@ -24,7 +25,6 @@ function JobStatusService (moment, message) { this.state = { running: false, - stats: false, counts: { plays: 0, tasks: 0, @@ -41,13 +41,37 @@ function JobStatusService (moment, message) { }, }; - this.setStatsEvent(stats); - this.updateStats(); - this.updateRunningState(); + if (model.get('type') === 'job' || model.get('type') === 'project_update') { + if (model.has('playbook_counts')) { + this.setPlaybookCounts(model.get('playbook_counts')); + } + if (model.has('host_status_counts')) { + this.setHostStatusCounts(model.get('host_status_counts')); + } + } else { + const hostStatusCounts = this.createHostStatusCounts(this.state.status); + + this.setHostStatusCounts(hostStatusCounts); + this.setPlaybookCounts({ task_count: 1, play_count: 1 }); + } + + this.updateRunningState(); this.dispatch(); }; + this.createHostStatusCounts = status => { + if (_.includes(COMPLETE, status)) { + return { ok: 1 }; + } + + if (_.includes(INCOMPLETE, status)) { + return { failures: 1 }; + } + + return null; + }; + this.pushStatusEvent = data => { const isJobStatusEvent = (this.job === data.unified_job_id); const isProjectStatusEvent = (this.project && (this.project === data.project_id)); @@ -112,7 +136,6 @@ function JobStatusService (moment, message) { this.updateHostCounts(); if (this.statsEvent) { - this.state.stats = true; this.setFinished(this.statsEvent.created); this.setJobStatus(this.statsEvent.failed ? 'failed' : 'successful'); } @@ -199,9 +222,25 @@ function JobStatusService (moment, message) { }; this.setHostStatusCounts = counts => { + counts = counts || {}; + + HOST_STATUS_KEYS.forEach(key => { + counts[key] = counts[key] || 0; + }); + + if (!this.state.counts.hosts) { + this.state.counts.hosts = Object.keys(counts) + .reduce((sum, key) => sum + counts[key], 0); + } + this.state.hosts = counts; }; + this.setPlaybookCounts = ({ play_count, task_count }) => { + this.state.counts.plays = play_count; + this.state.counts.tasks = task_count; + }; + this.resetCounts = () => { this.state.counts.plays = 0; this.state.counts.tasks = 0; diff --git a/awx/ui/client/lib/models/AdHocCommand.js b/awx/ui/client/lib/models/AdHocCommand.js index 7bea2677ac..9f259a929a 100644 --- a/awx/ui/client/lib/models/AdHocCommand.js +++ b/awx/ui/client/lib/models/AdHocCommand.js @@ -19,17 +19,12 @@ function postRelaunch (params) { return $http(req); } -function getStats () { - return Promise.resolve(null); -} - function AdHocCommandModel (method, resource, config) { BaseModel.call(this, 'ad_hoc_commands'); this.Constructor = AdHocCommandModel; this.postRelaunch = postRelaunch.bind(this); this.getRelaunch = getRelaunch.bind(this); - this.getStats = getStats.bind(this); return this.create(method, resource, config); } diff --git a/awx/ui/client/lib/models/InventoryUpdate.js b/awx/ui/client/lib/models/InventoryUpdate.js index 668a05459d..37951911d7 100644 --- a/awx/ui/client/lib/models/InventoryUpdate.js +++ b/awx/ui/client/lib/models/InventoryUpdate.js @@ -1,14 +1,8 @@ let BaseModel; -function getStats () { - return Promise.resolve(null); -} - function InventoryUpdateModel (method, resource, config) { BaseModel.call(this, 'inventory_updates'); - this.getStats = getStats.bind(this); - this.Constructor = InventoryUpdateModel; return this.create(method, resource, config); diff --git a/awx/ui/client/lib/models/Job.js b/awx/ui/client/lib/models/Job.js index 1e466b0e6d..7e2f017826 100644 --- a/awx/ui/client/lib/models/Job.js +++ b/awx/ui/client/lib/models/Job.js @@ -23,31 +23,6 @@ function postRelaunch (params) { return $http(req); } -function getStats () { - if (!this.has('GET', 'id')) { - return Promise.reject(new Error('No property, id, exists')); - } - - if (!this.has('GET', 'related.job_events')) { - return Promise.reject(new Error('No related property, job_events, exists')); - } - - const req = { - method: 'GET', - url: `${this.path}${this.get('id')}/job_events/`, - params: { event: 'playbook_on_stats' }, - }; - - return $http(req) - .then(({ data }) => { - if (data.results.length > 0) { - return data.results[0]; - } - - return null; - }); -} - function getCredentials (id) { const req = { method: 'GET', @@ -64,7 +39,6 @@ function JobModel (method, resource, config) { this.postRelaunch = postRelaunch.bind(this); this.getRelaunch = getRelaunch.bind(this); - this.getStats = getStats.bind(this); this.getCredentials = getCredentials.bind(this); return this.create(method, resource, config); diff --git a/awx/ui/client/lib/models/ProjectUpdate.js b/awx/ui/client/lib/models/ProjectUpdate.js index df038283cf..a8b1ae1fe9 100644 --- a/awx/ui/client/lib/models/ProjectUpdate.js +++ b/awx/ui/client/lib/models/ProjectUpdate.js @@ -1,50 +1,20 @@ -let $http; let BaseModel; -function getStats () { - if (!this.has('GET', 'id')) { - return Promise.reject(new Error('No property, id, exists')); - } - - if (!this.has('GET', 'related.events')) { - return Promise.reject(new Error('No related property, events, exists')); - } - - const req = { - method: 'GET', - url: `${this.path}${this.get('id')}/events/`, - params: { event: 'playbook_on_stats' }, - }; - - return $http(req) - .then(({ data }) => { - if (data.results.length > 0) { - return data.results[0]; - } - - return null; - }); -} - function ProjectUpdateModel (method, resource, config) { BaseModel.call(this, 'project_updates'); - this.getStats = getStats.bind(this); - this.Constructor = ProjectUpdateModel; return this.create(method, resource, config); } -function ProjectUpdateModelLoader (_$http_, _BaseModel_) { - $http = _$http_; +function ProjectUpdateModelLoader (_BaseModel_) { BaseModel = _BaseModel_; return ProjectUpdateModel; } ProjectUpdateModelLoader.$inject = [ - '$http', 'BaseModel' ]; diff --git a/awx/ui/client/lib/models/SystemJob.js b/awx/ui/client/lib/models/SystemJob.js index 1f1f1c5ee3..cc092ff8f4 100644 --- a/awx/ui/client/lib/models/SystemJob.js +++ b/awx/ui/client/lib/models/SystemJob.js @@ -1,14 +1,8 @@ let BaseModel; -function getStats () { - return Promise.resolve(null); -} - function SystemJobModel (method, resource, config) { BaseModel.call(this, 'system_jobs'); - this.getStats = getStats.bind(this); - this.Constructor = SystemJobModel; return this.create(method, resource, config);