diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less index 989b532ab6..ab4e589213 100644 --- a/awx/ui/client/features/output/_index.less +++ b/awx/ui/client/features/output/_index.less @@ -199,3 +199,85 @@ width: 100%; flex-wrap: wrap; } + + +// Status Bar ----------------------------------------------------------------------------- +.HostStatusBar { + display: flex; + flex: 0 0 auto; + width: 100%; + margin-top: 10px; +} + +.HostStatusBar-ok, +.HostStatusBar-changed, +.HostStatusBar-unreachable, +.HostStatusBar-failed, +.HostStatusBar-skipped, +.HostStatusBar-noData { + height: 15px; + border-top: 5px solid @default-bg; + border-bottom: 5px solid @default-bg; +} + +.HostStatusBar-ok { + background-color: @default-succ; + display: flex; + flex: 0 0 auto; +} + +.HostStatusBar-changed { + background-color: @default-warning; + flex: 0 0 auto; +} + +.HostStatusBar-unreachable { + background-color: @default-unreachable; + flex: 0 0 auto; +} + +.HostStatusBar-failed { + background-color: @default-err; + flex: 0 0 auto; +} + +.HostStatusBar-skipped { + background-color: @default-link; + flex: 0 0 auto; +} + +.HostStatusBar-noData { + background-color: @default-icon-hov; + flex: 1 0 auto; +} + +.HostStatusBar-tooltipLabel { + text-transform: uppercase; + margin-right: 15px; +} + +.HostStatusBar-tooltipBadge { + border-radius: 5px; + border: 1px solid @default-bg; +} + +.HostStatusBar-tooltipBadge--ok { + background-color: @default-succ; +} + +.HostStatusBar-tooltipBadge--unreachable { + background-color: @default-unreachable; +} + +.HostStatusBar-tooltipBadge--skipped { + background-color: @default-link; +} + +.HostStatusBar-tooltipBadge--changed { + background-color: @default-warning; +} + +.HostStatusBar-tooltipBadge--failed { + background-color: @default-err; + +} diff --git a/awx/ui/client/features/output/details.directive.js b/awx/ui/client/features/output/details.directive.js index 2393284913..309c50612d 100644 --- a/awx/ui/client/features/output/details.directive.js +++ b/awx/ui/client/features/output/details.directive.js @@ -2,6 +2,7 @@ const templateUrl = require('~features/output/details.partial.html'); let $http; let $filter; +let $scope; let $state; let error; @@ -12,68 +13,86 @@ let strings; let wait; function mapChoices (choices) { - return Object.assign(...choices.map(([k, v]) => ({[k]: v}))); + if (!choices) return {}; + return Object.assign(...choices.map(([k, v]) => ({ [k]: v }))); } function getStatusDetails (status) { - const value = status || resource.model.get('status'); - const label = 'Status'; + const unmapped = status || resource.model.get('status'); + + if (!unmapped) { + return null; + } + const choices = mapChoices(resource.model.options('actions.GET.status.choices')); - const displayValue = choices[value]; + const label = 'Status'; + const icon = `fa icon-job-${unmapped}`; + const value = choices[unmapped]; - return { displayValue, label, value }; + return { label, icon, value }; } function getStartTimeDetails (started) { - const value = started || resource.model.get('started'); + const unfiltered = started || resource.model.get('started'); + const label = 'Started'; - let displayValue; + let value; - if (value) { - displayValue = $filter('longDate')(value); + if (unfiltered) { + value = $filter('longDate')(unfiltered); } else { - displayValue = 'Not Started'; + value = 'Not Started'; } - return { displayValue, label, value }; + return { label, value }; } function getFinishTimeDetails (finished) { - const value = finished || resource.model.get('finished'); + const unfiltered = finished || resource.model.get('finished'); + const label = 'Finished'; - let displayValue; + let value; - if (value) { - displayValue = $filter('longDate')(value); + if (unfiltered) { + value = $filter('longDate')(unfiltered); } else { - displayValue = 'Not Finished'; + value = 'Not Finished'; } - return { displayValue, label, value }; + return { label, value }; } function getJobTypeDetails () { - const value = resource.model.get('job_type'); - const label = 'Job Type'; + const unmapped = resource.model.get('job_type'); + + if (!unmapped) { + return null; + } + const choices = mapChoices(resource.model.options('actions.GET.job_type.choices')); - const displayValue = choices[value]; + const label = 'Job Type'; + const value = choices[unmapped]; - return { displayValue, label, value }; + return { label, value }; } - function getVerbosityDetails () { - const value = resource.model.get('verbosity'); + const verbosity = resource.model.get('verbosity'); + + if (!verbosity) { + return null; + } + const choices = mapChoices(resource.model.options('actions.GET.verbosity.choices')); - const displayValue = choices[value]; const label = 'Verbosity'; + const value = choices[value]; - return { displayValue, label, value }; + return { label, value }; } function getSourceWorkflowJobDetails () { @@ -273,7 +292,6 @@ function getLimitDetails () { } function getInstanceGroupDetails () { - const instanceGroup = resource.model.get('summary_fields.instance_group'); if (!instanceGroup) { @@ -336,9 +354,9 @@ function getLabelDetails () { } const label = 'Labels'; - const value = jobLabels.map(({ name }) => name).map($filter('sanitize')); + const more = false; - let more = false; + const value = jobLabels.map(({ name }) => name).map($filter('sanitize')); return { label, more, value }; } @@ -396,9 +414,7 @@ function cancelJob () { prompt({ hdr, resourceName, body, actionText, action }); } -function deleteJob () { - return; -} +function deleteJob () {} function AtDetailsController ( _$http_, @@ -418,21 +434,18 @@ function AtDetailsController ( $state = _$state_; error = _error_; - // resource = _resource_; parse = ParseVariableString; prompt = _prompt_; strings = _strings_; wait = _wait_; - // statusChoices = mapChoices(resource.options('status.choices')); + vm.init = _$scope_ => { + $scope = _$scope_; + resource = $scope.resource; - vm.init = scope => { - vm.job = scope.job || {}; - resource = scope.resource; - - vm.status = getStatusDetails(scope.status); - vm.startTime = getStartTimeDetails(); - vm.finishTime = getFinishTimeDetails(); + vm.status = getStatusDetails(); + vm.started = getStartTimeDetails(); + vm.finished = getFinishTimeDetails(); vm.jobType = getJobTypeDetails(); vm.jobTemplate = getJobTemplateDetails(); vm.sourceWorkflowJob = getSourceWorkflowJobDetails(); @@ -457,12 +470,24 @@ function AtDetailsController ( vm.deleteJob = deleteJob; vm.toggleLabels = toggleLabels; - // codemirror - const cm = { parseType: 'yaml', variables: vm.extraVars.value, $apply: scope.$apply }; - ParseTypeChange({ scope: cm, field_id: 'cm-extra-vars', readOnly: true }); + const observe = (key, transform) => { + $scope.$watch(key, value => { this[key] = transform(value); }); + }; - scope.$watch('status', value => { vm.status = getStatusDetails(value); }); - } + observe('status', getStatusDetails); + observe('started', getStartTimeDetails); + observe('finished', getFinishTimeDetails); + + // relaunch component + $scope.job = _.get(resource.model, 'model.GET', {}); + this.job = $scope.job; + + // codemirror + if (this.extraVars) { + const cm = { parseType: 'yaml', variables: this.extraVars.value, $apply: $scope.$apply }; + ParseTypeChange({ scope: cm, field_id: 'cm-extra-vars', readOnly: true }); + } + }; } AtDetailsController.$inject = [ @@ -492,9 +517,10 @@ function atDetails () { link: atDetailsLink, controller: AtDetailsController, scope: { - job: '=', - status: '=', resource: '=', + status: '=', + started: '=', + finished: '=', }, }; } diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html index 2741d43d0a..bb80346a7b 100644 --- a/awx/ui/client/features/output/details.partial.html +++ b/awx/ui/client/features/output/details.partial.html @@ -36,25 +36,25 @@
- - {{ vm.status.displayValue | translate }} + + {{ vm.status.value }}
-
- +
+
- {{ vm.startTime.displayValue }} + {{ vm.started.value }}
-
- +
+
- {{ vm.finishTime.displayValue }} + {{ vm.finished.value }}
@@ -81,7 +81,7 @@
-
{{ vm.jobType.displayValue }}
+
{{ vm.jobType.value }}
@@ -164,7 +164,7 @@
-
{{ vm.verbosity.displayValue }}
+
{{ vm.verbosity.value }}
diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 055e9c9a95..a2fc44a8a8 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -10,6 +10,8 @@ let resource; let $state; let qs; +let hack; + function JobsIndexController ( _resource_, _page_, @@ -51,7 +53,7 @@ function JobsIndexController ( vm.expand = expand; vm.isExpanded = true; - // search + // Search $state = _$state_; qs = _qs_; @@ -67,17 +69,47 @@ function JobsIndexController ( vm.removeSearchTag = removeSearchTag; vm.searchTags = getSearchTags(getCurrentQueryset()); - // details + // Host Status Bar + vm.status = { + running: Boolean(resource.model.get('started')) && !resource.model.get('finished'), + stats: resource.stats, + } + + // Details vm.details = { - job: resource.model.model.GET, - status: resource.model.model.GET.status, resource, + started: resource.model.get('started'), + finished: resource.model.get('finished'), + status: resource.model.get('status'), }; render.requestAnimationFrame(() => init()); } +function onStreamStart (data) { + const status = _.get(data, 'summary_fields.job.status'); + + if (!hack) { + hack = true; + vm.details.status = status; + vm.details.started = data.created; + + vm.status.running = true; + } +} + +function onStreamFinish (data) { + const failed = _.get(data, 'summary_fields.job.failed'); + + vm.details.status = failed ? 'failed' : 'successful'; + vm.details.finished = data.created; + + vm.status = { stats: data, running: false }; +}; + function init (pageMode) { + hack = false; + page.init({ resource, }); @@ -98,10 +130,12 @@ function init (pageMode) { page, scroll, resource, + onStreamStart, + onStreamFinish, render: events => shift().then(() => append(events, true)), listen: (namespace, listener) => { $scope.$on(namespace, (scope, data) => listener(data)); - } + }, }); if (pageMode) { diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index 367f5c1f89..707b333adb 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -6,13 +6,16 @@ import Controller from '~features/output/index.controller'; import PageService from '~features/output/page.service'; import RenderService from '~features/output/render.service'; import ScrollService from '~features/output/scroll.service'; -import SearchKeyDirective from '~features/output/search-key.directive'; import StreamService from '~features/output/stream.service'; -import DetailsDirective from '~features/output/details.directive.js'; + +import DetailsDirective from '~features/output/details.directive'; +import SearchKeyDirective from '~features/output/search-key.directive'; +import StatusDirective from '~features/output/status.directive'; const Template = require('~features/output/index.view.html'); const MODULE_NAME = 'at.features.output'; + const PAGE_CACHE = true; const PAGE_LIMIT = 5; const PAGE_SIZE = 50; @@ -66,13 +69,21 @@ function resolveResource ( Wait('start'); return new Resource(['get', 'options'], [id, id]) - .then(model => Promise.all([ - model.extend('labels'), - model.extend(related, config) - ])) - .then(([ model ]) => ({ + .then(model => { + const promises = [model.getStats()]; + + if (model.has('related.labels')) { + promises.push(model.extend('labels')); + } + + promises.push(model.extend(related, config)); + + return Promise.all(promises); + }) + .then(([stats, model]) => ({ id, type, + stats, model, related, ws: { @@ -200,6 +211,7 @@ angular .service('JobStreamService', StreamService) .directive('atDetails', DetailsDirective) .directive('atSearchKey', SearchKeyDirective) + .directive('atStatus', StatusDirective) .run(JobsRun); export default MODULE_NAME; diff --git a/awx/ui/client/features/output/index.view.html b/awx/ui/client/features/output/index.view.html index 778a44d7d8..8034b13182 100644 --- a/awx/ui/client/features/output/index.view.html +++ b/awx/ui/client/features/output/index.view.html @@ -1,14 +1,20 @@
- -

+ + +

- + +
- +
({ [key]: 0 }))); + + HOST_STATUS_KEYS.forEach(key => { + const hostData = _.get(statsEvent, ['event_data', key], {}); + + Object.keys(hostData).forEach(hostName => { + const isAlreadyCounted = (countedHostNames.indexOf(hostName) > -1); + const shouldBeCounted = ((!isAlreadyCounted) && hostData[hostName] > 0); + + if (shouldBeCounted) { + countedHostNames.push(hostName); + counts[key]++; + } + }); + }); + + return counts; +} + +function createStatusBarTooltip (key, count) { + const label = `${key}`; + const badge = `${count}`; + + return `${label}${badge}`; +} + +function atStatusLink (scope, el, attrs, controllers) { + const [atStatusController] = controllers; + + atStatusController.init(scope); +} + +function AtStatusController (strings) { + const vm = this || {}; + + vm.tooltips = { + running: strings.get('status.RUNNING'), + unavailable: strings.get('status.UNAVAILABLE'), + }; + + vm.init = scope => { + const { running, stats } = scope; + + vm.running = running || false; + vm.setStats(stats); + + scope.$watch('running', value => { vm.running = value; }); + scope.$watch('stats', vm.setStats); + }; + + vm.setStats = stats => { + const counts = getHostStatusCounts(stats); + + HOST_STATUS_KEYS.forEach(key => { + const count = counts[key]; + const statusBarElement = $(`.HostStatusBar-${key}`); + + statusBarElement.css('flex', `${count} 0 auto`); + + vm.tooltips[key] = createStatusBarTooltip(key, count); + }); + + vm.statsAreAvailable = Boolean(stats); + }; +} + +function atStatus () { + return { + templateUrl, + restrict: 'E', + require: ['atStatus'], + controllerAs: 'vm', + link: atStatusLink, + controller: [ + 'JobStrings', + AtStatusController + ], + scope: { + running: '=', + stats: '=', + }, + }; +} + +export default atStatus; diff --git a/awx/ui/client/features/output/status.partial.html b/awx/ui/client/features/output/status.partial.html new file mode 100644 index 0000000000..6608a23262 --- /dev/null +++ b/awx/ui/client/features/output/status.partial.html @@ -0,0 +1,37 @@ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/features/output/stream.service.js b/awx/ui/client/features/output/stream.service.js index d7f8a72555..0ce0199ac4 100644 --- a/awx/ui/client/features/output/stream.service.js +++ b/awx/ui/client/features/output/stream.service.js @@ -3,7 +3,7 @@ const JOB_END = 'playbook_on_stats'; const MAX_LAG = 120; function JobStreamService ($q) { - this.init = ({ resource, scroll, page, render, listen }) => { + this.init = ({ resource, scroll, page, onStreamStart, onStreamFinish, render, listen }) => { this.resource = resource; this.scroll = scroll; this.page = page; @@ -23,8 +23,10 @@ function JobStreamService ($q) { }; this.hooks = { + onStreamStart, + onStreamFinish, render, - listen + listen, }; this.lines = { @@ -35,7 +37,7 @@ function JobStreamService ($q) { max: 0 }; - this.hooks.listen(resource.ws.namespace, this.listen); + this.hooks.listen(resource.ws.namespace, this.listener); }; this.getBatchFactors = size => { @@ -105,19 +107,25 @@ function JobStreamService ($q) { } }; - this.listen = data => { + this.listener = data => { this.lag++; this.chain = this.chain .then(() => { + // console.log(data); if (!this.isActive()) { this.start(); + if (!this.isEnding()) { + this.hooks.onStreamStart(data); + } } else if (data.event === JOB_END) { if (this.isPaused()) { this.end(true); } else { this.end(); } + + this.hooks.onStreamFinish(data); } this.checkLines(data); diff --git a/awx/ui/client/lib/models/Job.js b/awx/ui/client/lib/models/Job.js index ef80c5dafb..07b5da3174 100644 --- a/awx/ui/client/lib/models/Job.js +++ b/awx/ui/client/lib/models/Job.js @@ -23,26 +23,54 @@ 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 JobModel (method, resource, config) { BaseModel.call(this, 'jobs'); this.Constructor = JobModel; + this.postRelaunch = postRelaunch.bind(this); this.getRelaunch = getRelaunch.bind(this); + this.getStats = getStats.bind(this); return this.create(method, resource, config); } -function JobModelLoader (_BaseModel_, _$http_) { - BaseModel = _BaseModel_; +function JobModelLoader (_$http_, _BaseModel_) { $http = _$http_; + BaseModel = _BaseModel_; return JobModel; } JobModelLoader.$inject = [ + '$http', 'BaseModel', - '$http' ]; export default JobModelLoader; diff --git a/awx/ui/client/lib/models/ProjectUpdate.js b/awx/ui/client/lib/models/ProjectUpdate.js index 84fae23f50..f600eb7e7c 100644 --- a/awx/ui/client/lib/models/ProjectUpdate.js +++ b/awx/ui/client/lib/models/ProjectUpdate.js @@ -1,19 +1,54 @@ +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 }) => { + console.log(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; + this.Constructor = ProjectUpdateModel; return this.create(method, resource, config); } -function ProjectUpdateModelLoader (_BaseModel_) { +function ProjectUpdateModelLoader (_$http_, _BaseModel_) { + $http = _$http_; BaseModel = _BaseModel_; return ProjectUpdateModel; } -ProjectUpdateModelLoader.$inject = ['BaseModel']; +ProjectUpdateModelLoader.$inject = [ + '$http', + 'BaseModel' +]; export default ProjectUpdateModelLoader;