diff --git a/awx/ui/build/webpack.watch.js b/awx/ui/build/webpack.watch.js index d653707847..5bf1aad89c 100644 --- a/awx/ui/build/webpack.watch.js +++ b/awx/ui/build/webpack.watch.js @@ -20,16 +20,16 @@ const watch = { output: { filename: OUTPUT }, - module: { - rules: [ - { - test: /\.js$/, - enforce: 'pre', - exclude: /node_modules/, - loader: 'eslint-loader' - } - ] - }, + module: { + rules: [ + { + test: /\.js$/, + enforce: 'pre', + exclude: /node_modules/, + loader: 'eslint-loader' + } + ] + }, plugins: [ new HtmlWebpackHarddiskPlugin(), new HardSourceWebpackPlugin({ @@ -53,25 +53,37 @@ const watch = { host: '127.0.0.1', https: true, port: 3000, - https: true, - proxy: { - '/': { - target: TARGET, - secure: false, - ws: false, - bypass: req => req.originalUrl.includes('hot-update.json') - }, - '/websocket': { - target: TARGET, - secure: false, - ws: true - }, - '/network_ui': { - target: TARGET, - secure: false, - ws: true + clientLogLevel: 'none', + proxy: [{ + context: (pathname, req) => !(pathname === '/api/login/' && req.method === 'POST'), + target: TARGET, + secure: false, + ws: false, + bypass: req => req.originalUrl.includes('hot-update.json') + }, + { + context: '/api/login/', + target: TARGET, + secure: false, + ws: false, + headers: { + Host: `localhost:${TARGET_PORT}`, + Origin: TARGET, + Referer: `${TARGET}/` } - } + }, + { + context: '/websocket', + target: TARGET, + secure: false, + ws: true + }, + { + context: '/network_ui', + target: TARGET, + secure: false, + ws: true + }] } }; diff --git a/awx/ui/client/features/_index.less b/awx/ui/client/features/_index.less index e2339dc9e4..59e8e4630b 100644 --- a/awx/ui/client/features/_index.less +++ b/awx/ui/client/features/_index.less @@ -1,2 +1,3 @@ @import 'credentials/_index'; +@import 'output/_index'; @import 'users/tokens/_index'; diff --git a/awx/ui/client/features/index.js b/awx/ui/client/features/index.js index 763894c93c..0a6ec3864c 100644 --- a/awx/ui/client/features/index.js +++ b/awx/ui/client/features/index.js @@ -4,6 +4,7 @@ import atLibModels from '~models'; import atFeaturesApplications from '~features/applications'; import atFeaturesCredentials from '~features/credentials'; +import atFeaturesOutput from '~features/output'; import atFeaturesTemplates from '~features/templates'; import atFeaturesUsers from '~features/users'; import atFeaturesJobs from '~features/jobs'; @@ -18,7 +19,9 @@ angular.module(MODULE_NAME, [ atFeaturesCredentials, atFeaturesTemplates, atFeaturesUsers, - atFeaturesJobs + atFeaturesJobs, + atFeaturesOutput, + atFeaturesTemplates ]); export default MODULE_NAME; diff --git a/awx/ui/client/features/jobs/jobsList.controller.js b/awx/ui/client/features/jobs/jobsList.controller.js index b78a1b77b6..ea57c4e9f5 100644 --- a/awx/ui/client/features/jobs/jobsList.controller.js +++ b/awx/ui/client/features/jobs/jobsList.controller.js @@ -52,19 +52,19 @@ function ListJobsController ( switch (type) { case 'job': - link = `/#/jobs/${id}`; + link = `/#/jobz/playbook/${id}`; break; case 'ad_hoc_command': - link = `/#/ad_hoc_commands/${id}`; + link = `/#/jobz/command/${id}`; break; case 'system_job': - link = `/#/management_jobs/${id}`; + link = `/#/jobz/system/${id}`; break; case 'project_update': - link = `/#/scm_update/${id}`; + link = `/#/jobz/project/${id}`; break; case 'inventory_update': - link = `/#/inventory_sync/${id}`; + link = `/#/jobz/inventory/${id}`; break; case 'workflow_job': link = `/#/workflows/${id}`; diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less new file mode 100644 index 0000000000..71228d20e7 --- /dev/null +++ b/awx/ui/client/features/output/_index.less @@ -0,0 +1,534 @@ +@import 'host-event/_index'; +.at-Stdout { + &-menuTop { + color: @at-gray-848992; + border: 1px solid @at-gray-b7; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom: none; + + & > div { + user-select: none; + } + } + + &-menuBottom { + color: @at-gray-848992; + font-size: 10px; + text-transform: uppercase; + font-weight: bold; + position: absolute; + right: 60px; + bottom: 24px; + cursor: pointer; + + &:hover { + color: @at-blue; + } + } + + &-menuIconGroup { + & > p { + margin: 0; + } + + & > p:first-child { + font-size: 20px; + margin-right: 8px; + } + + & > p:last-child { + margin-top: 9px; + } + } + + &-menuIcon { + font-size: 12px; + padding: 10px; + cursor: pointer; + + &:hover { + color: @at-blue; + } + } + + &-menuIcon--lg { + font-size: 22px; + line-height: 12px; + font-weight: bold; + padding: 10px; + cursor: pointer; + + &:hover { + color: @at-blue; + } + } + + &-menuIcon--active { + font-size: 22px; + line-height: 12px; + font-weight: bold; + padding: 10px; + cursor: pointer; + color: @at-blue; + } + + &-toggle { + color: @at-gray-848992; + background-color: @at-gray-eb; + font-size: 18px; + line-height: 12px; + + & > i { + cursor: pointer; + } + + padding: 0 10px 0 10px; + user-select: none; + } + + &-line { + color: @at-gray-161b1f; + background-color: @at-gray-eb; + text-align: right; + vertical-align: top; + padding-right: 5px; + border-right: 1px solid @at-gray-b7; + user-select: none; + } + + &-event { + .at-mixin-event(); + } + + &-event--host { + .at-mixin-event(); + + cursor: pointer; + } + + &-time { + padding-right: 2ch; + font-size: 12px; + text-align: right; + user-select: none; + width: 11ch; + + & > span { + background-color: white; + border-radius: 4px; + padding: 1px 2px; + } + } + + &-container { + font-family: monospace; + height: calc(~"100vh - 240px"); + overflow-y: scroll; + font-size: 15px; + border: 1px solid @at-gray-b7; + background-color: @at-gray-f2; + color: @at-gray-161b1f; + padding: 0; + margin: 0; + border-radius: 0; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + + & > table { + table-layout: fixed; + + tr:hover > td { + background: white; + } + } + } +} + +.at-mixin-event() { + padding: 0 10px; + word-wrap: break-word; + white-space: pre-wrap; + +} + +// Search --------------------------------------------------------------------------------- +@at-jobz-top-search-key: @at-space-2x; +@at-jobz-bottom-search-key: @at-space-3x; + +.jobz-searchKeyPaneContainer { + margin-top: @at-jobz-top-search-key; + margin-bottom: @at-jobz-bottom-search-key; +} + +.jobz-searchKeyPane { + // background-color: @at-gray-f6; + background-color: @login-notice-bg; + color: @login-notice-text; + border-radius: @at-border-radius; + border: 1px solid @at-gray-b7; + // color: @at-gray-848992; + padding: 6px @at-padding-input 6px @at-padding-input; +} + +.jobz-searchClearAll { + font-size: 10px; + padding-bottom: @at-space; +} + +.jobz-Button-searchKey { + .at-mixin-Button(); + + background-color: @at-blue; + border-color: at-color-button-border-default; + color: @at-white; + + &:hover, &:active { + color: @at-white; + background-color: @at-blue-hover; + box-shadow: none; + } + + &:focus { + color: @at-white; + } +} + +.jobz-tagz { + margin-top: @at-space; + display: flex; + width: 100%; + flex-wrap: wrap; +} + + +// Status Bar ----------------------------------------------------------------------------- +.HostStatusBar { + display: flex; + flex: 0 0 auto; + width: 100%; +} + +.HostStatusBar-ok, +.HostStatusBar-changed, +.HostStatusBar-dark, +.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-dark { + 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--dark { + 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; + +} + +// Job Details --------------------------------------------------------------------------------- + +@breakpoint-md: 1200px; + +.JobResults { + .OnePlusTwo-container(100%, @breakpoint-md); + + &.fullscreen { + .JobResults-rightSide { + max-width: 100%; + } + } +} + +.JobResults-leftSide { + .OnePlusTwo-left--panel(100%, @breakpoint-md); + max-width: 30%; + height: ~"calc(100vh - 177px)"; + + @media screen and (max-width: @breakpoint-md) { + max-width: 100%; + } +} + +.JobResults-rightSide { + .OnePlusTwo-right--panel(100%, @breakpoint-md); + height: ~"calc(100vh - 177px)"; + + @media (max-width: @breakpoint-md - 1px) { + padding-right: 15px; + } +} + +.JobResults-detailsPanel{ + overflow-y: scroll; +} + +.JobResults-stdoutActionButton--active { + display: none; + visibility: hidden; + flex:none; + width:0px; + padding-right: 0px; +} + +.JobResults-panelHeader { + display: flex; + height: 30px; +} + +.JobResults-panelHeaderText { + color: @default-interface-txt; + flex: 1 0 auto; + font-size: 14px; + font-weight: bold; + margin-right: 10px; + text-transform: uppercase; +} + +.JobResults-panelHeaderButtonActions { + display: flex; +} + +.JobResults-resultRow { + width: 100%; + display: flex; + padding-bottom: 10px; + flex-wrap: wrap; +} + +.JobResults-resultRow--variables { + flex-direction: column; + + #cm-variables-container { + width: 100%; + } +} + +.JobResults-resultRowLabel { + text-transform: uppercase; + color: @default-interface-txt; + font-size: 12px; + font-weight: normal!important; + width: 30%; + margin-right: 20px; + + @media screen and (max-width: @breakpoint-md) { + flex: 2.5 0 auto; + } +} + +.JobResults-resultRowLabel--fullWidth { + width: 100%; + margin-right: 0px; +} + +.JobResults-resultRowText { + width: ~"calc(70% - 20px)"; + flex: 1 0 auto; + text-transform: none; + word-wrap: break-word; +} + +.JobResults-resultRowText--fullWidth { + width: 100%; +} + +.JobResults-expandArrow { + color: #D7D7D7; + font-size: 14px; + font-weight: bold; + margin-right: 10px; + text-transform: uppercase; + margin-left: 10px; +} + +.JobResults-resultRowText--instanceGroup { + display: flex; +} + +.JobResults-isolatedBadge { + align-items: center; + background-color: @default-list-header-bg; + border-radius: 5px; + color: @default-stdout-txt; + display: flex; + font-size: 10px; + height: 16px; + margin: 3px 0 0 10px; + padding: 0 10px; + text-transform: uppercase; +} + +.JobResults-statusResultIcon { + padding-left: 0px; + padding-right: 10px; +} + +.JobResults-badgeRow { + display: flex; + align-items: center; + margin-right: 5px; +} + +.JobResults-badgeTitle{ + color: @default-interface-txt; + font-size: 14px; + margin-right: 10px; + font-weight: normal; + text-transform: uppercase; + margin-left: 20px; +} + +@media (max-width: @breakpoint-md) { + .JobResults-detailsPanel { + overflow-y: auto; + } + + .JobResults-rightSide { + height: inherit; + } +} + +.JobResults-timeBadge { + float:right; + font-size: 11px; + font-weight: normal; + padding: 1px 10px; + height: 14px; + margin: 3px 15px; + width: 80px; + background-color: @default-bg; + border-radius: 5px; + color: @default-interface-txt; + margin-right: -5px; +} + +.JobResults-panelRight { + display: flex; + flex-direction: column; +} + +.JobResults-panelRight .SmartSearch-bar { + width: 100%; +} + +.JobResults-panelRightTitle{ + flex-wrap: wrap; +} + +.JobResults-panelRightTitleText{ + word-wrap: break-word; + word-break: break-all; + max-width: 100%; +} + +.JobResults-badgeAndActionRow{ + display:flex; + flex: 1 0 auto; + justify-content: flex-end; + flex-wrap: wrap; + max-width: 100%; +} + +.StandardOut-panelHeader { + flex: initial; +} + +.StandardOut-panelHeader--jobIsRunning { + margin-bottom: 20px; +} + +host-status-bar { + flex: initial; + margin-bottom: 20px; +} + +smart-search { + flex: initial; +} + +job-results-standard-out { + flex: 1; + flex-basis: auto; + height: ~"calc(100% - 800px)"; + display: flex; + border: 1px solid @d7grey; + border-radius: 5px; + margin-top: 20px; +} +@media screen and (max-width: @breakpoint-md) { + job-results-standard-out { + height: auto; + } +} + +.JobResults-extraVarsHelp { + margin-left: 10px; + color: @default-icon; +} + +.JobResults-seeMoreLess { + color: #337AB7; + margin: 4px 0px; + text-transform: uppercase; + padding: 2px 0px; + cursor: pointer; + border-radius: 5px; + font-size: 11px; +} \ No newline at end of file diff --git a/awx/ui/client/features/output/details.directive.js b/awx/ui/client/features/output/details.directive.js new file mode 100644 index 0000000000..3c0e677d9b --- /dev/null +++ b/awx/ui/client/features/output/details.directive.js @@ -0,0 +1,577 @@ +const templateUrl = require('~features/output/details.partial.html'); + +let $http; +let $filter; +let $scope; +let $state; + +let error; +let parse; +let prompt; +let resource; +let strings; +let status; +let wait; + +let vm; + +function mapChoices (choices) { + if (!choices) return {}; + return Object.assign(...choices.map(([k, v]) => ({ [k]: v }))); +} + +function getStatusDetails (jobStatus) { + const unmapped = jobStatus || resource.model.get('status'); + + if (!unmapped) { + return null; + } + + const choices = mapChoices(resource.model.options('actions.GET.status.choices')); + + const label = 'Status'; + const icon = `fa icon-job-${unmapped}`; + const value = choices[unmapped]; + + return { label, icon, value }; +} + +function getStartDetails (started) { + const unfiltered = started || resource.model.get('started'); + + const label = 'Started'; + + let value; + + if (unfiltered) { + value = $filter('longDate')(unfiltered); + } else { + value = 'Not Started'; + } + + return { label, value }; +} + +function getFinishDetails (finished) { + const unfiltered = finished || resource.model.get('finished'); + + const label = 'Finished'; + + let value; + + if (unfiltered) { + value = $filter('longDate')(unfiltered); + } else { + value = 'Not Finished'; + } + + return { label, value }; +} + +function getJobTypeDetails () { + const unmapped = resource.model.get('job_type'); + + if (!unmapped) { + return null; + } + + const choices = mapChoices(resource.model.options('actions.GET.job_type.choices')); + + const label = 'Job Type'; + const value = choices[unmapped]; + + return { label, value }; +} + +function getVerbosityDetails () { + const verbosity = resource.model.get('verbosity'); + + if (!verbosity) { + return null; + } + + const choices = mapChoices(resource.model.options('actions.GET.verbosity.choices')); + + const label = 'Verbosity'; + const value = choices[verbosity]; + + return { label, value }; +} + +function getSourceWorkflowJobDetails () { + const sourceWorkflowJob = resource.model.get('summary_fields.source_workflow_job'); + + if (!sourceWorkflowJob) { + return null; + } + + const link = `/#/workflows/${sourceWorkflowJob.id}`; + + return { link }; +} + +function getJobTemplateDetails () { + const jobTemplate = resource.model.get('summary_fields.job_template'); + + if (!jobTemplate) { + return null; + } + + const label = 'Job Template'; + const link = `/#/templates/job_template/${jobTemplate.id}`; + const value = $filter('sanitize')(jobTemplate.name); + + return { label, link, value }; +} + +function getLaunchedByDetails () { + const createdBy = resource.model.get('summary_fields.created_by'); + const jobTemplate = resource.model.get('summary_fields.job_template'); + + const relatedSchedule = resource.model.get('related.schedule'); + const schedule = resource.model.get('summary_fields.schedule'); + + if (!createdBy && !schedule) { + return null; + } + + const label = 'Launched By'; + + let link; + let tooltip; + let value; + + if (createdBy) { + tooltip = 'Edit the User'; + link = `/#/users/${createdBy.id}`; + value = $filter('sanitize')(createdBy.username); + } else if (relatedSchedule && jobTemplate) { + tooltip = 'Edit the Schedule'; + link = `/#/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`; + value = $filter('sanitize')(schedule.name); + } else { + tooltip = null; + link = null; + value = $filter('sanitize')(schedule.name); + } + + return { label, link, tooltip, value }; +} + +function getInventoryDetails () { + const inventory = resource.model.get('summary_fields.inventory'); + + if (!inventory) { + return null; + } + + const label = 'Inventory'; + const tooltip = 'Edit the inventory'; + const value = $filter('sanitize')(inventory.name); + + let link; + + if (inventory.kind === 'smart') { + link = `/#/inventories/smart/${inventory.id}`; + } else { + link = `/#/inventories/inventory/${inventory.id}`; + } + + return { label, link, tooltip, value }; +} + +function getProjectDetails () { + const project = resource.model.get('summary_fields.project'); + const projectUpdate = resource.model.get('summary_fields.project_update'); + + if (!project) { + return null; + } + + const label = 'Project'; + const link = `/#/projects/${project.id}`; + const value = $filter('sanitize')(project.name); + + if (projectUpdate) { + const update = { + link: `/#/jobz/project/${projectUpdate.id}`, + tooltip: 'View project checkout results', + status: projectUpdate.status, + }; + + return { label, link, value, update }; + } + + return { label, link, value }; +} + +function getSCMRevisionDetails () { + const label = 'Revision'; + const value = resource.model.get('scm_revision'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getPlaybookDetails () { + const label = 'Playbook'; + const value = resource.model.get('playbook'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getJobExplanationDetails () { + const jobExplanation = resource.model.get('job_explanation'); + + if (!jobExplanation) { + return null; + } + + const value = null; + + return { value }; +} + +function getResultTracebackDetails () { + const previousTaskFailed = false; + const resultTraceback = resource.model.get('result_traceback'); + + if (!resultTraceback) { + return null; + } + + if (!previousTaskFailed) { + return null; + } + + const label = 'Results Traceback'; + const value = null; + + return { label, value }; +} + +function getCredentialDetails () { + const credential = resource.model.get('summary_fields.credential'); + + if (!credential) { + return null; + } + + let label = 'Credential'; + + if (resource.type === 'playbook') { + label = 'Machine Credential'; + } + + if (resource.type === 'inventory') { + label = 'Source Credential'; + } + + const link = `/#/credentials/${credential.id}`; + const tooltip = 'Edit the Credential'; + const value = $filter('sanitize')(credential.name); + + return { label, link, tooltip, value }; +} + +function getForkDetails () { + const label = 'Forks'; + const value = resource.model.get('forks'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getLimitDetails () { + const label = 'Limit'; + const value = resource.model.get('limit'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getInstanceGroupDetails () { + const instanceGroup = resource.model.get('summary_fields.instance_group'); + + if (!instanceGroup) { + return null; + } + + const label = 'Instance Group'; + const value = $filter('sanitize')(instanceGroup.name); + + let isolated = null; + + if (instanceGroup.controller_id) { + isolated = 'Isolated'; + } + + return { label, value, isolated }; +} + +function getJobTagDetails () { + const label = 'Job Tags'; + const value = resource.model.get('job_tags'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getSkipTagDetails () { + const label = 'Skip Tags'; + const value = resource.model.get('skip_tags'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getExtraVarsDetails () { + const extraVars = resource.model.get('extra_vars'); + + if (!extraVars) { + return null; + } + + const label = 'Extra Variables'; + const tooltip = 'Read-only view of extra variables added to the job template.'; + const value = parse(extraVars); + + return { label, tooltip, value }; +} + +function getLabelDetails () { + const jobLabels = _.get(resource.model.get('related.labels'), 'results', []); + + if (jobLabels.length < 1) { + return null; + } + + const label = 'Labels'; + const more = false; + + const value = jobLabels.map(({ name }) => name).map($filter('sanitize')); + + return { label, more, value }; +} + +function createErrorHandler (path, action) { + return res => { + const hdr = strings.get('error.HEADER'); + const msg = strings.get('error.CALL', { path, action, status: res.status }); + + error($scope, res.data, res.status, null, { hdr, msg }); + }; +} + +const ELEMENT_LABELS = '#job-results-labels'; +const ELEMENT_PROMPT_MODAL = '#prompt-modal'; +const LABELS_SLIDE_DISTANCE = 200; + +function toggleLabels () { + if (!this.labels.more) { + $(ELEMENT_LABELS).slideUp(LABELS_SLIDE_DISTANCE); + this.labels.more = true; + } else { + $(ELEMENT_LABELS).slideDown(LABELS_SLIDE_DISTANCE); + this.labels.more = false; + } +} + +function cancelJob () { + const actionText = strings.get('warnings.CANCEL_ACTION'); + const hdr = strings.get('warnings.CANCEL_HEADER'); + const warning = strings.get('warnings.CANCEL_BODY'); + + const id = resource.model.get('id'); + const name = $filter('sanitize')(resource.model.get('name')); + + const body = `
${warning}
`; + const resourceName = `#${id} ${name}`; + + const method = 'POST'; + const url = `${resource.model.path}/${id}/cancel/`; + + const errorHandler = createErrorHandler('cancel job', method); + + const action = () => { + wait('start'); + $http({ method, url }) + .catch(errorHandler) + .finally(() => { + $(ELEMENT_PROMPT_MODAL).modal('hide'); + wait('stop'); + }); + }; + + prompt({ hdr, resourceName, body, actionText, action }); +} + +function deleteJob () { + const actionText = strings.get('DELETE'); + const hdr = strings.get('warnings.DELETE_HEADER'); + const warning = strings.get('warnings.DELETE_BODY'); + + const id = resource.model.get('id'); + const name = $filter('sanitize')(resource.model.get('name')); + + const body = `
${warning}
`; + const resourceName = `#${id} ${name}`; + + const method = 'DELETE'; + const url = `${resource.model.path}/${id}/`; + + const errorHandler = createErrorHandler('delete job', method); + + const action = () => { + wait('start'); + $http({ method, url }) + .then(() => $state.go('jobs')) + .catch(errorHandler) + .finally(() => { + $(ELEMENT_PROMPT_MODAL).modal('hide'); + wait('stop'); + }); + }; + + prompt({ hdr, resourceName, body, actionText, action }); +} + +function AtJobDetailsController ( + _$http_, + _$filter_, + _$state_, + _error_, + _prompt_, + _strings_, + _status_, + _wait_, + ParseTypeChange, + ParseVariableString, +) { + vm = this || {}; + + $http = _$http_; + $filter = _$filter_; + $state = _$state_; + + error = _error_; + parse = ParseVariableString; + prompt = _prompt_; + strings = _strings_; + status = _status_; + wait = _wait_; + + vm.init = _$scope_ => { + $scope = _$scope_; + resource = $scope.resource; // eslint-disable-line prefer-destructuring + + vm.status = getStatusDetails(); + vm.started = getStartDetails(); + vm.finished = getFinishDetails(); + vm.jobType = getJobTypeDetails(); + vm.jobTemplate = getJobTemplateDetails(); + vm.sourceWorkflowJob = getSourceWorkflowJobDetails(); + vm.inventory = getInventoryDetails(); + vm.project = getProjectDetails(); + vm.scmRevision = getSCMRevisionDetails(); + vm.playbook = getPlaybookDetails(); + vm.resultTraceback = getResultTracebackDetails(); + vm.launchedBy = getLaunchedByDetails(); + vm.jobExplanation = getJobExplanationDetails(); + vm.verbosity = getVerbosityDetails(); + vm.credential = getCredentialDetails(); + vm.forks = getForkDetails(); + vm.limit = getLimitDetails(); + vm.instanceGroup = getInstanceGroupDetails(); + vm.jobTags = getJobTagDetails(); + vm.skipTags = getSkipTagDetails(); + vm.extraVars = getExtraVarsDetails(); + vm.labels = getLabelDetails(); + + // Relaunch and Delete Components + vm.job = _.get(resource.model, 'model.GET', {}); + vm.canDelete = resource.model.get('summary_fields.user_capabilities.delete'); + + // XX - Codemirror + if (vm.extraVars) { + const cm = { + parseType: 'yaml', + $apply: $scope.$apply, + variables: vm.extraVars.value, + }; + + ParseTypeChange({ + field_id: 'cm-extra-vars', + readOnly: true, + scope: cm, + }); + } + + vm.cancelJob = cancelJob; + vm.deleteJob = deleteJob; + vm.toggleLabels = toggleLabels; + + $scope.$watch(status.getStarted, value => { vm.started = getStartDetails(value); }); + $scope.$watch(status.getJobStatus, value => { vm.status = getStatusDetails(value); }); + $scope.$watch(status.getFinished, value => { vm.finished = getFinishDetails(value); }); + + $scope.$watch(status.getProjectStatus, value => { + if (!value) return; + + vm.project.update = vm.project.update || {}; + vm.project.update.status = value; + }); + }; +} + +AtJobDetailsController.$inject = [ + '$http', + '$filter', + '$state', + 'ProcessErrors', + 'Prompt', + 'JobStrings', + 'JobStatusService', + 'Wait', + 'ParseTypeChange', + 'ParseVariableString', +]; + +function atJobDetailsLink (scope, el, attrs, controllers) { + const [atDetailsController] = controllers; + + atDetailsController.init(scope); +} + +function atJobDetails () { + return { + templateUrl, + restrict: 'E', + require: ['atJobDetails'], + controllerAs: 'vm', + link: atJobDetailsLink, + controller: AtJobDetailsController, + scope: { resource: '=', }, + }; +} + +export default atJobDetails; diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html new file mode 100644 index 0000000000..9417d30670 --- /dev/null +++ b/awx/ui/client/features/output/details.partial.html @@ -0,0 +1,241 @@ + +
+
+
DETAILS
+ +
+ + + + + + + + +
+
+ + +
+ +
+ +
+ + {{ vm.status.value }} +
+
+ + + +
+ +
+ {{ vm.started.value }} +
+
+ + +
+ +
+ {{ vm.finished.value }} +
+
+ + +
+ +
+
+ + +
+ + +
+ + +
+ +
{{ vm.jobType.value }}
+
+ + +
+ + +
+ {{ vm.launchedBy.value }} +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
{{ vm.playbook.value }}
+
+ + +
+ + +
+ + +
+ +
{{ vm.forks.value }}
+
+ + +
+ +
{{ vm.limit.value }}
+
+ + +
+ +
{{ vm.verbosity.value }}
+
+ + +
+ +
+ {{ vm.instanceGroup.value }} + + {{ vm.instanceGroup.isolated }} + +
+
+ + +
+ +
{{ vm.jobTags.value }}
+
+ + +
+ +
{{ vm.skipTags.value }}
+
+ + +
+ + +
+ + +
+ +
+
+
{{ label }}
+
+
+
+
diff --git a/awx/ui/client/features/output/engine.service.js b/awx/ui/client/features/output/engine.service.js new file mode 100644 index 0000000000..1f74a90c59 --- /dev/null +++ b/awx/ui/client/features/output/engine.service.js @@ -0,0 +1,226 @@ +const JOB_END = 'playbook_on_stats'; +const MAX_LAG = 120; + +function JobEventEngine ($q) { + this.init = ({ resource, scroll, page, onEventFrame, onStart, onStop }) => { + this.resource = resource; + this.scroll = scroll; + this.page = page; + + this.lag = 0; + this.count = 0; + this.pageCount = 0; + this.chain = $q.resolve(); + this.factors = this.getBatchFactors(this.resource.page.size); + + this.state = { + started: false, + paused: false, + pausing: false, + resuming: false, + ending: false, + ended: false, + counting: false, + }; + + this.hooks = { + onEventFrame, + onStart, + onStop, + }; + + this.lines = { + used: [], + missing: [], + ready: false, + min: 0, + max: 0 + }; + }; + + this.getBatchFactors = size => { + const factors = [1]; + + for (let i = 2; i <= size / 2; i++) { + if (size % i === 0) { + factors.push(i); + } + } + + factors.push(size); + + return factors; + }; + + this.getBatchFactorIndex = () => { + const index = Math.floor((this.lag / MAX_LAG) * this.factors.length); + + return index > this.factors.length - 1 ? this.factors.length - 1 : index; + }; + + this.setBatchFrameCount = () => { + const index = this.getBatchFactorIndex(); + + this.framesPerRender = this.factors[index]; + }; + + this.buffer = data => { + const pageAdded = this.page.addToBuffer(data); + + this.pageCount++; + + if (pageAdded) { + this.setBatchFrameCount(); + + if (this.isPausing()) { + this.pause(true); + } else if (this.isResuming()) { + this.resume(true); + } + } + }; + + this.checkLines = data => { + for (let i = data.start_line; i < data.end_line; i++) { + if (i > this.lines.max) { + this.lines.max = i; + } + + this.lines.used.push(i); + } + + const missing = []; + for (let i = this.lines.min; i < this.lines.max; i++) { + if (this.lines.used.indexOf(i) === -1) { + missing.push(i); + } + } + + if (missing.length === 0) { + this.lines.ready = true; + this.lines.min = this.lines.max + 1; + this.lines.used = []; + } else { + this.lines.ready = false; + } + }; + + this.pushJobEvent = data => { + this.lag++; + + this.chain = this.chain + .then(() => { + if (!this.isActive()) { + this.start(); + } else if (data.event === JOB_END) { + if (this.isPaused()) { + this.end(true); + } else { + this.end(); + } + } + + this.checkLines(data); + this.buffer(data); + this.count++; + + if (!this.isReadyToRender()) { + return $q.resolve(); + } + + const events = this.page.emptyBuffer(); + this.count -= events.length; + + return this.renderFrame(events); + }) + .then(() => --this.lag); + + return this.chain; + }; + + this.renderFrame = events => this.hooks.onEventFrame(events) + .then(() => { + if (this.scroll.isLocked()) { + this.scroll.scrollToBottom(); + } + + if (this.isEnding()) { + const lastEvents = this.page.emptyBuffer(); + + if (lastEvents.length) { + return this.renderFrame(lastEvents); + } + + this.end(true); + } + + return $q.resolve(); + }); + + this.resume = done => { + if (done) { + this.state.resuming = false; + this.state.paused = false; + } else if (!this.isTransitioning()) { + this.scroll.pause(); + this.scroll.lock(); + this.scroll.scrollToBottom(); + this.state.resuming = true; + this.page.removeBookmark(); + } + }; + + this.pause = done => { + if (done) { + this.state.pausing = false; + this.state.paused = true; + this.scroll.resume(); + } else if (!this.isTransitioning()) { + this.scroll.pause(); + this.scroll.unlock(); + this.state.pausing = true; + this.page.setBookmark(); + } + }; + + this.start = () => { + if (!this.state.ending && !this.state.ended) { + this.state.started = true; + this.scroll.pause(); + this.scroll.lock(); + + this.hooks.onStart(); + } + }; + + this.end = done => { + if (done) { + this.state.ending = false; + this.state.ended = true; + this.scroll.unlock(); + this.scroll.resume(); + + this.hooks.onStop(); + + return; + } + + this.state.ending = true; + }; + + this.isReadyToRender = () => this.isDone() || + (!this.isPaused() && this.hasAllLines() && this.isBatchFull()); + this.hasAllLines = () => this.lines.ready; + this.isBatchFull = () => this.count % this.framesPerRender === 0; + this.isPaused = () => this.state.paused; + this.isPausing = () => this.state.pausing; + this.isResuming = () => this.state.resuming; + this.isTransitioning = () => this.isActive() && (this.state.pausing || this.state.resuming); + this.isActive = () => this.state.started && !this.state.ended; + this.isEnding = () => this.state.ending; + this.isDone = () => this.state.ended; +} + +JobEventEngine.$inject = ['$q']; + +export default JobEventEngine; diff --git a/awx/ui/client/src/job-results/host-event/host-event.block.less b/awx/ui/client/features/output/host-event/_index.less similarity index 99% rename from awx/ui/client/src/job-results/host-event/host-event.block.less rename to awx/ui/client/features/output/host-event/_index.less index 6153466934..bec8548cd2 100644 --- a/awx/ui/client/src/job-results/host-event/host-event.block.less +++ b/awx/ui/client/features/output/host-event/_index.less @@ -15,6 +15,7 @@ } .HostEvent .CodeMirror{ overflow-x: hidden; + max-height: none!important; } .HostEvent-close:hover{ diff --git a/awx/ui/client/src/job-results/host-event/host-event-codemirror.partial.html b/awx/ui/client/features/output/host-event/host-event-codemirror.partial.html similarity index 100% rename from awx/ui/client/src/job-results/host-event/host-event-codemirror.partial.html rename to awx/ui/client/features/output/host-event/host-event-codemirror.partial.html diff --git a/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html b/awx/ui/client/features/output/host-event/host-event-modal.partial.html similarity index 83% rename from awx/ui/client/src/job-results/host-event/host-event-modal.partial.html rename to awx/ui/client/features/output/host-event/host-event-modal.partial.html index 7da83dfb43..a79b3cde68 100644 --- a/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html +++ b/awx/ui/client/features/output/host-event/host-event-modal.partial.html @@ -40,19 +40,19 @@
- - - diff --git a/awx/ui/client/src/job-results/host-event/host-event-stderr.partial.html b/awx/ui/client/features/output/host-event/host-event-stderr.partial.html similarity index 100% rename from awx/ui/client/src/job-results/host-event/host-event-stderr.partial.html rename to awx/ui/client/features/output/host-event/host-event-stderr.partial.html diff --git a/awx/ui/client/src/job-results/host-event/host-event-stdout.partial.html b/awx/ui/client/features/output/host-event/host-event-stdout.partial.html similarity index 100% rename from awx/ui/client/src/job-results/host-event/host-event-stdout.partial.html rename to awx/ui/client/features/output/host-event/host-event-stdout.partial.html diff --git a/awx/ui/client/features/output/host-event/host-event.controller.js b/awx/ui/client/features/output/host-event/host-event.controller.js new file mode 100644 index 0000000000..67105ba7a0 --- /dev/null +++ b/awx/ui/client/features/output/host-event/host-event.controller.js @@ -0,0 +1,170 @@ +function HostEventsController ( + $scope, + $state, + HostEventService, + hostEvent +) { + $scope.processEventStatus = HostEventService.processEventStatus; + $scope.processResults = processResults; + $scope.isActiveState = isActiveState; + $scope.getActiveHostIndex = getActiveHostIndex; + $scope.closeHostEvent = closeHostEvent; + + function init () { + hostEvent.event_name = hostEvent.event; + $scope.event = _.cloneDeep(hostEvent); + + // grab standard out & standard error if present from the host + // event's 'res' object, for things like Ansible modules. Small + // wrinkle in this implementation is that the stdout/stderr tabs + // should be shown if the `res` object has stdout/stderr keys, even + // if they're a blank string. The presence of these keys is + // potentially significant to a user. + if (_.has(hostEvent.event_data, 'task_action')) { + $scope.module_name = hostEvent.event_data.task_action; + } else if (!_.has(hostEvent.event_data, 'task_action')) { + $scope.module_name = 'No result found'; + } + + if (_.has(hostEvent.event_data, 'res.result.stdout')) { + if (hostEvent.event_data.res.stdout === '') { + $scope.stdout = ' '; + } else { + $scope.stdout = hostEvent.event_data.res.stdout; + } + } + + if (_.has(hostEvent.event_data, 'res.result.stderr')) { + if (hostEvent.event_data.res.stderr === '') { + $scope.stderr = ' '; + } else { + $scope.stderr = hostEvent.event_data.res.stderr; + } + } + + if (_.has(hostEvent.event_data, 'res')) { + $scope.json = hostEvent.event_data.res; + } + + if ($scope.module_name === 'debug' && + _.has(hostEvent.event_data, 'res.result.stdout')) { + $scope.stdout = hostEvent.event_data.res.result.stdout; + } + if ($scope.module_name === 'yum' && + _.has(hostEvent.event_data, 'res.results') && + _.isArray(hostEvent.event_data.res.results)) { + const event = hostEvent.event_data.res.results; + $scope.stdout = event[0];// eslint-disable-line prefer-destructuring + } + // instantiate Codemirror + if ($state.current.name === 'jobz.host-event.json') { + try { + if (_.has(hostEvent.event_data, 'res')) { + initCodeMirror( + 'HostEvent-codemirror', + JSON.stringify($scope.json, null, 4), + { name: 'javascript', json: true } + ); + resize(); + } else { + $scope.no_json = true; + } + } catch (err) { + // element with id HostEvent-codemirror is not the view + // controlled by this instance of HostEventController + } + } else if ($state.current.name === 'jobz.host-event.stdout') { + try { + resize(); + } catch (err) { + // element with id HostEvent-codemirror is not the view + // controlled by this instance of HostEventController + } + } else if ($state.current.name === 'jobz.host-event.stderr') { + try { + resize(); + } catch (err) { + // element with id HostEvent-codemirror is not the view + // controlled by this instance of HostEventController + } + } + $('#HostEvent').modal('show'); + $('.modal-content').resizable({ + minHeight: 523, + minWidth: 600 + }); + $('.modal-dialog').draggable({ + cancel: '.HostEvent-view--container' + }); + + function resize () { + if ($state.current.name === 'jobz.host-event.json') { + const editor = $('.CodeMirror')[0].CodeMirror; + const height = $('.modal-dialog').height() - $('.HostEvent-header').height() - $('.HostEvent-details').height() - $('.HostEvent-nav').height() - $('.HostEvent-controls').height() - 120; + editor.setSize('100%', height); + } else if ($state.current.name === 'jobz.host-event.stdout' || $state.current.name === 'jobz.host-event.stderr') { + const height = $('.modal-dialog').height() - $('.HostEvent-header').height() - $('.HostEvent-details').height() - $('.HostEvent-nav').height() - $('.HostEvent-controls').height() - 120; + $('.HostEvent-stdout').width('100%'); + $('.HostEvent-stdout').height(height); + $('.HostEvent-stdoutContainer').height(height); + $('.HostEvent-numberColumnPreload').height(height); + } + } + + $('.modal-dialog').on('resize', resize); + + $('#HostEvent').on('hidden.bs.modal', $scope.closeHostEvent); + } + + function processResults (value) { + if (typeof value === 'object') { + return false; + } + return true; + } + + function initCodeMirror (el, data, mode) { + const container = document.getElementById(el); + const options = {}; + options.lineNumbers = true; + options.mode = mode; + options.readOnly = true; + options.scrollbarStyle = null; + const editor = CodeMirror.fromTextArea(// eslint-disable-line no-undef + container, + options + ); + editor.setSize('100%', 200); + editor.getDoc().setValue(data); + } + + function isActiveState (name) { + return $state.current.name === name; + } + + function getActiveHostIndex () { + function hostResultfilter (obj) { + return obj.id === $scope.event.id; + } + const result = $scope.hostResults.filter(hostResultfilter); + return $scope.hostResults.indexOf(result[0]); + } + + function closeHostEvent () { + // Unbind the listener so it doesn't fire when we close the modal via navigation + $('#HostEvent').off('hidden.bs.modal'); + $('#HostEvent').modal('hide'); + $state.go('jobz'); + } + $scope.init = init; + $scope.init(); +} + +HostEventsController.$inject = [ + '$scope', + '$state', + 'HostEventService', + 'hostEvent', +]; + +module.exports = HostEventsController; diff --git a/awx/ui/client/features/output/host-event/host-event.route.js b/awx/ui/client/features/output/host-event/host-event.route.js new file mode 100644 index 0000000000..06f3eeac51 --- /dev/null +++ b/awx/ui/client/features/output/host-event/host-event.route.js @@ -0,0 +1,72 @@ +const HostEventModalTemplate = require('~features/output/host-event/host-event-modal.partial.html'); +const HostEventCodeMirrorTemplate = require('~features/output/host-event/host-event-codemirror.partial.html'); +const HostEventStdoutTemplate = require('~features/output/host-event/host-event-stdout.partial.html'); +const HostEventStderrTemplate = require('~features/output/host-event/host-event-stderr.partial.html'); + +function exit () { + // close the modal + // using an onExit event to handle cases where the user navs away + // using the url bar / back and not modal "X" + $('#HostEvent').modal('hide'); + // hacky way to handle user browsing away via URL bar + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); +} + +function HostEventResolve (HostEventService, $stateParams) { + return HostEventService.getRelatedJobEvents($stateParams.id, { + id: $stateParams.eventId + }).then((response) => response.data.results[0]); +} + +HostEventResolve.$inject = [ + 'HostEventService', + '$stateParams', +]; + +const hostEventModal = { + name: 'jobz.host-event', + url: '/host-event/:eventId', + controller: 'HostEventsController', + templateUrl: HostEventModalTemplate, + abstract: false, + ncyBreadcrumb: { + skip: true + }, + resolve: { + hostEvent: HostEventResolve + }, + onExit: exit +}; + +const hostEventJson = { + name: 'jobz.host-event.json', + url: '/json', + controller: 'HostEventsController', + templateUrl: HostEventCodeMirrorTemplate, + ncyBreadcrumb: { + skip: true + }, +}; + +const hostEventStdout = { + name: 'jobz.host-event.stdout', + url: '/stdout', + controller: 'HostEventsController', + templateUrl: HostEventStdoutTemplate, + ncyBreadcrumb: { + skip: true + }, +}; + +const hostEventStderr = { + name: 'jobz.host-event.stderr', + url: '/stderr', + controller: 'HostEventsController', + templateUrl: HostEventStderrTemplate, + ncyBreadcrumb: { + skip: true + }, +}; + +export { hostEventJson, hostEventModal, hostEventStdout, hostEventStderr }; diff --git a/awx/ui/client/features/output/host-event/host-event.service.js b/awx/ui/client/features/output/host-event/host-event.service.js new file mode 100644 index 0000000000..a1e6952725 --- /dev/null +++ b/awx/ui/client/features/output/host-event/host-event.service.js @@ -0,0 +1,69 @@ +function HostEventService ( + Rest, + ProcessErrors, + GetBasePath, + $rootScope +) { + // GET events related to a job run + // e.g. + // ?event=playbook_on_stats + // ?parent=206&event__startswith=runner&page_size=200&order=host_name,counter + this.getRelatedJobEvents = (id, params) => { + let url = GetBasePath('jobs'); + url = `${url}${id}/job_events/?${this.stringifyParams(params)}`; + Rest.setUrl(url); + return Rest.get() + .then(response => response) + .catch(({ data, status }) => { + ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', + msg: `Call to ${url}. GET returned: ${status}` }); + }); + }; + + this.stringifyParams = params => { + function reduceFunction (result, value, key) { + return `${result}${key}=${value}&`; + } + return _.reduce(params, reduceFunction, ''); + }; + + // Generate a helper class for job_event statuses + // the stack for which status to display is + // unreachable > failed > changed > ok + // uses the API's runner events and convenience properties .failed .changed to determine status. + // see: job_event_callback.py for more filters to support + this.processEventStatus = event => { + const obj = {}; + if (event.event === 'runner_on_unreachable') { + obj.class = 'HostEvent-status--unreachable'; + obj.status = 'unreachable'; + } + // equiv to 'runner_on_error' && 'runner on failed' + if (event.failed) { + obj.class = 'HostEvent-status--failed'; + obj.status = 'failed'; + } + // catch the changed case before ok, because both can be true + if (event.changed) { + obj.class = 'HostEvent-status--changed'; + obj.status = 'changed'; + } + if (event.event === 'runner_on_ok' || event.event === 'runner_on_async_ok') { + obj.class = 'HostEvent-status--ok'; + obj.status = 'ok'; + } + if (event.event === 'runner_on_skipped') { + obj.class = 'HostEvent-status--skipped'; + obj.status = 'skipped'; + } + return obj; + }; +} + +HostEventService.$inject = [ + 'Rest', + 'ProcessErrors', + 'GetBasePath', + '$rootScope' +]; +export default HostEventService; diff --git a/awx/ui/client/features/output/host-event/index.js b/awx/ui/client/features/output/host-event/index.js new file mode 100644 index 0000000000..f00b0b36b4 --- /dev/null +++ b/awx/ui/client/features/output/host-event/index.js @@ -0,0 +1,26 @@ +import { + hostEventModal, + hostEventJson, + hostEventStdout, + hostEventStderr +} from './host-event.route'; +import controller from './host-event.controller'; +import service from './host-event.service'; + +const MODULE_NAME = 'hostEvents'; + +function hostEventRun ($stateExtender) { + $stateExtender.addState(hostEventModal); + $stateExtender.addState(hostEventJson); + $stateExtender.addState(hostEventStdout); + $stateExtender.addState(hostEventStderr); +} +hostEventRun.$inject = [ + '$stateExtender' +]; + +angular.module(MODULE_NAME, []) + .controller('HostEventsController', controller) + .service('HostEventService', service) + .run(hostEventRun); +export default MODULE_NAME; diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js new file mode 100644 index 0000000000..0fee736561 --- /dev/null +++ b/awx/ui/client/features/output/index.controller.js @@ -0,0 +1,336 @@ +let $compile; +let $q; +let $scope; +let page; +let render; +let resource; +let scroll; +let engine; +let status; + +let vm; + +function JobsIndexController ( + _resource_, + _page_, + _scroll_, + _render_, + _engine_, + _$scope_, + _$compile_, + _$q_, + _status_, +) { + vm = this || {}; + + $compile = _$compile_; + $scope = _$scope_; + $q = _$q_; + resource = _resource_; + + page = _page_; + scroll = _scroll_; + render = _render_; + engine = _engine_; + status = _status_; + + // Development helper(s) + vm.clear = devClear; + + // Expand/collapse + // vm.toggle = toggle; + // vm.expand = expand; + vm.isExpanded = true; + + // Panel + vm.resource = resource; + vm.title = resource.model.get('name'); + + // Stdout Navigation + vm.scroll = { + showBackToTop: false, + home: scrollHome, + end: scrollEnd, + down: scrollPageDown, + up: scrollPageUp + }; + + render.requestAnimationFrame(() => init()); +} + +function init () { + status.init({ + resource, + }); + + page.init({ + resource, + }); + + render.init({ + get: () => resource.model.get(`related.${resource.related}.results`), + compile: html => $compile(html)($scope), + isStreamActive: engine.isActive, + }); + + scroll.init({ + isAtRest: scrollIsAtRest, + previous, + next, + }); + + engine.init({ + page, + scroll, + resource, + onEventFrame (events) { + return shift().then(() => append(events, true)); + }, + onStart () { + status.resetCounts(); + status.setJobStatus('running'); + }, + onStop () { + status.updateStats(); + } + }); + + $scope.$on(resource.ws.events, handleSocketEvent); + $scope.$on(resource.ws.status, handleStatusEvent); + + if (!status.isRunning()) { + next(); + } +} + +function handleStatusEvent (scope, data) { + status.pushStatusEvent(data); +} + +function handleSocketEvent (scope, data) { + engine.pushJobEvent(data); + + status.pushJobEvent(data); +} + +function devClear (pageMode) { + init(pageMode); + render.clear(); +} + +function next () { + return page.next() + .then(events => { + if (!events) { + return $q.resolve(); + } + + return shift() + .then(() => append(events)) + .then(() => { + if (scroll.isMissing()) { + return next(); + } + + return $q.resolve(); + }); + }); +} + +function previous () { + const initialPosition = scroll.getScrollPosition(); + let postPopHeight; + + return page.previous() + .then(events => { + if (!events) { + return $q.resolve(); + } + + return pop() + .then(() => { + postPopHeight = scroll.getScrollHeight(); + + return prepend(events); + }) + .then(() => { + const currentHeight = scroll.getScrollHeight(); + scroll.setScrollPosition(currentHeight - postPopHeight + initialPosition); + }); + }); +} + +function append (events, eng) { + return render.append(events) + .then(count => { + page.updateLineCount(count, eng); + }); +} + +function prepend (events) { + return render.prepend(events) + .then(count => { + page.updateLineCount(count); + }); +} + +function pop () { + if (!page.isOverCapacity()) { + return $q.resolve(); + } + + const lines = page.trim(); + + return render.pop(lines); +} + +function shift () { + if (!page.isOverCapacity()) { + return $q.resolve(); + } + + const lines = page.trim(true); + + return render.shift(lines); +} + +function scrollHome () { + if (scroll.isPaused()) { + return $q.resolve(); + } + + scroll.pause(); + + return page.first() + .then(events => { + if (!events) { + return $q.resolve(); + } + + return render.clear() + .then(() => prepend(events)) + .then(() => { + scroll.resetScrollPosition(); + scroll.resume(); + }) + .then(() => { + if (scroll.isMissing()) { + return next(); + } + + return $q.resolve(); + }); + }); +} + +function scrollEnd () { + if (engine.isActive()) { + if (engine.isTransitioning()) { + return $q.resolve(); + } + + if (engine.isPaused()) { + engine.resume(); + } else { + engine.pause(); + } + + return $q.resolve(); + } else if (scroll.isPaused()) { + return $q.resolve(); + } + + scroll.pause(); + + return page.last() + .then(events => { + if (!events) { + return $q.resolve(); + } + + return render.clear() + .then(() => append(events)) + .then(() => { + scroll.setScrollPosition(scroll.getScrollHeight()); + scroll.resume(); + }); + }); +} + +function scrollPageUp () { + if (scroll.isPaused()) { + return; + } + + scroll.pageUp(); +} + +function scrollPageDown () { + if (scroll.isPaused()) { + return; + } + + scroll.pageDown(); +} + +function scrollIsAtRest (isAtRest) { + vm.scroll.showBackToTop = !isAtRest; +} + +// function expand () { +// vm.toggle(parent, true); +// } + +// function showHostDetails (id) { +// jobEvent.request('get', id) +// .then(() => { +// const title = jobEvent.get('host_name'); + +// vm.host = { +// menu: true, +// stdout: jobEvent.get('stdout') +// }; + +// $scope.jobs.modal.show(title); +// }); +// } + +// function toggle (uuid, menu) { +// const lines = $(`.child-of-${uuid}`); +// let icon = $(`#${uuid} .at-Stdout-toggle > i`); + +// if (menu || record[uuid].level === 1) { +// vm.isExpanded = !vm.isExpanded; +// } + +// if (record[uuid].children) { +// icon = icon.add($(`#${record[uuid].children.join(', #')}`) +// .find('.at-Stdout-toggle > i')); +// } + +// if (icon.hasClass('fa-angle-down')) { +// icon.addClass('fa-angle-right'); +// icon.removeClass('fa-angle-down'); + +// lines.addClass('hidden'); +// } else { +// icon.addClass('fa-angle-down'); +// icon.removeClass('fa-angle-right'); + +// lines.removeClass('hidden'); +// } +// } + +JobsIndexController.$inject = [ + 'resource', + 'JobPageService', + 'JobScrollService', + 'JobRenderService', + 'JobEventEngine', + '$scope', + '$compile', + '$q', + 'JobStatusService', +]; + +module.exports = JobsIndexController; diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js new file mode 100644 index 0000000000..3aaadc2553 --- /dev/null +++ b/awx/ui/client/features/output/index.js @@ -0,0 +1,229 @@ +import atLibModels from '~models'; +import atLibComponents from '~components'; + +import Strings from '~features/output/jobs.strings'; +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 EngineService from '~features/output/engine.service'; +import StatusService from '~features/output/status.service'; + +import DetailsDirective from '~features/output/details.directive'; +import SearchDirective from '~features/output/search.directive'; +import StatsDirective from '~features/output/stats.directive'; +import HostEvent from './host-event/index'; + +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; +const WS_PREFIX = 'ws'; + +function resolveResource ( + Job, + ProjectUpdate, + AdHocCommand, + SystemJob, + WorkflowJob, + InventoryUpdate, + $stateParams, + qs, + Wait +) { + const { id, type, job_event_search } = $stateParams; // eslint-disable-line camelcase + const { name, key } = getWebSocketResource(type); + + let Resource; + let related = 'events'; + + switch (type) { + case 'project': + Resource = ProjectUpdate; + break; + case 'playbook': + Resource = Job; + related = 'job_events'; + break; + case 'command': + Resource = AdHocCommand; + break; + case 'system': + Resource = SystemJob; + break; + case 'inventory': + Resource = InventoryUpdate; + break; + // case 'workflow': + // todo: integrate workflow chart components into this view + // break; + default: + // Redirect + return null; + } + + const params = { page_size: PAGE_SIZE, order_by: 'start_line' }; + const config = { pageCache: PAGE_CACHE, pageLimit: PAGE_LIMIT, params }; + + if (job_event_search) { // eslint-disable-line camelcase + const queryParams = qs.encodeQuerysetObject(qs.decodeArr(job_event_search)); + + Object.assign(config.params, queryParams); + } + + Wait('start'); + return new Resource(['get', 'options'], [id, id]) + .then(model => { + const promises = [model.getStats()]; + + if (model.has('related.labels')) { + promises.push(model.extend('get', 'labels')); + } + + promises.push(model.extend('get', related, config)); + + return Promise.all(promises); + }) + .then(([stats, model]) => ({ + id, + type, + stats, + model, + related, + ws: { + events: `${WS_PREFIX}-${key}-${id}`, + status: `${WS_PREFIX}-${name}`, + }, + page: { + cache: PAGE_CACHE, + size: PAGE_SIZE, + pageLimit: PAGE_LIMIT + } + })) + .catch(({ data, status }) => qs.error(data, status)) + .finally(() => Wait('stop')); +} + +function resolveWebSocketConnection ($stateParams, SocketService) { + const { type, id } = $stateParams; + const { name, key } = getWebSocketResource(type); + + const state = { + data: { + socket: { + groups: { + [name]: ['status_changed', 'summary'], + [key]: [] + } + } + } + }; + + return SocketService.addStateResolve(state, id); +} + +function resolveBreadcrumb (strings) { + return { + label: strings.get('state.TITLE') + }; +} + +function getWebSocketResource (type) { + let name; + let key; + + switch (type) { + case 'system': + name = 'jobs'; + key = 'system_job_events'; + break; + case 'project': + name = 'jobs'; + key = 'project_update_events'; + break; + case 'command': + name = 'jobs'; + key = 'ad_hoc_command_events'; + break; + case 'inventory': + name = 'jobs'; + key = 'inventory_update_events'; + break; + case 'playbook': + name = 'jobs'; + key = 'job_events'; + break; + default: + throw new Error('Unsupported WebSocket type'); + } + + return { name, key }; +} + +function JobsRun ($stateRegistry) { + const state = { + name: 'jobz', + url: '/jobz/:type/:id?job_event_search', + route: '/jobz/:type/:id?job_event_search', + data: { + activityStream: true, + activityStreamTarget: 'jobs' + }, + views: { + '@': { + templateUrl: Template, + controller: Controller, + controllerAs: 'vm' + } + }, + resolve: { + resource: [ + 'JobModel', + 'ProjectUpdateModel', + 'AdHocCommandModel', + 'SystemJobModel', + 'WorkflowJobModel', + 'InventoryUpdateModel', + '$stateParams', + 'QuerySet', + 'Wait', + resolveResource + ], + ncyBreadcrumb: [ + 'JobStrings', + resolveBreadcrumb + ], + webSocketConnection: [ + '$stateParams', + 'SocketService', + resolveWebSocketConnection + ], + }, + }; + + $stateRegistry.register(state); +} + +JobsRun.$inject = ['$stateRegistry']; + +angular + .module(MODULE_NAME, [ + atLibModels, + atLibComponents, + HostEvent + ]) + .service('JobStrings', Strings) + .service('JobPageService', PageService) + .service('JobScrollService', ScrollService) + .service('JobRenderService', RenderService) + .service('JobEventEngine', EngineService) + .service('JobStatusService', StatusService) + .directive('atJobDetails', DetailsDirective) + .directive('atJobSearch', SearchDirective) + .directive('atJobStats', StatsDirective) + .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 new file mode 100644 index 0000000000..4e0a791bc4 --- /dev/null +++ b/awx/ui/client/features/output/index.view.html @@ -0,0 +1,49 @@ +
+
+ + + +
+ +
+ +
{{ vm.title }}
+ + + +
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ +
 
+ +
+
+

+

Back to Top

+
+ +
+
+
+
+
diff --git a/awx/ui/client/features/output/jobs.strings.js b/awx/ui/client/features/output/jobs.strings.js new file mode 100644 index 0000000000..af52a98472 --- /dev/null +++ b/awx/ui/client/features/output/jobs.strings.js @@ -0,0 +1,27 @@ +function JobsStrings (BaseString) { + BaseString.call(this, 'jobs'); + + const { t } = this; + const ns = this.jobs; + + ns.state = { + TITLE: t.s('JOBZ') + }; + + ns.warnings = { + CANCEL_ACTION: t.s('PROCEED'), + CANCEL_BODY: t.s('Are you sure you want to cancel this job?'), + CANCEL_HEADER: t.s('Cancel Job'), + DELETE_BODY: t.s('Are you sure you want to delete this job?'), + DELETE_HEADER: t.s('Delete Job'), + }; + + ns.status = { + RUNNING: t.s('The host status bar will update when the job is complete.'), + UNAVAILABLE: t.s('Host status information for this job unavailable.'), + }; +} + +JobsStrings.$inject = ['BaseStringService']; + +export default JobsStrings; diff --git a/awx/ui/client/features/output/page.service.js b/awx/ui/client/features/output/page.service.js new file mode 100644 index 0000000000..5f19fe921a --- /dev/null +++ b/awx/ui/client/features/output/page.service.js @@ -0,0 +1,298 @@ +function JobPageService ($q) { + this.init = ({ resource }) => { + this.resource = resource; + + this.page = { + limit: this.resource.page.pageLimit, + size: this.resource.page.size, + cache: [], + state: { + count: 0, + current: 0, + first: 0, + last: 0 + } + }; + + this.bookmark = { + pending: false, + set: false, + cache: [], + state: { + count: 0, + first: 0, + last: 0, + current: 0 + } + }; + + this.result = { + limit: this.page.limit * this.page.size, + count: 0 + }; + + this.buffer = { + count: 0 + }; + }; + + this.addPage = (number, events, push, reference) => { + const page = { number, events, lines: 0 }; + reference = reference || this.getActiveReference(); + + if (push) { + reference.cache.push(page); + reference.state.last = page.number; + reference.state.first = reference.cache[0].number; + } else { + reference.cache.unshift(page); + reference.state.first = page.number; + reference.state.last = reference.cache[reference.cache.length - 1].number; + } + + reference.state.current = page.number; + reference.state.count++; + }; + + this.addToBuffer = event => { + const reference = this.getReference(); + const index = reference.cache.length - 1; + let pageAdded = false; + + if (this.result.count % this.page.size === 0) { + this.addPage(reference.state.current + 1, [event], true, reference); + + if (this.isBookmarkPending()) { + this.setBookmark(); + } + + this.trimBuffer(); + + pageAdded = true; + } else { + reference.cache[index].events.push(event); + } + + this.buffer.count++; + this.result.count++; + + return pageAdded; + }; + + this.trimBuffer = () => { + const reference = this.getReference(); + const diff = reference.cache.length - this.page.limit; + + if (diff <= 0) { + return; + } + + for (let i = 0; i < diff; i++) { + if (reference.cache[i].events) { + this.buffer.count -= reference.cache[i].events.length; + reference.cache[i].events.splice(0, reference.cache[i].events.length); + } + } + }; + + this.isBufferFull = () => { + if (this.buffer.count === 2) { + return true; + } + + return false; + }; + + this.emptyBuffer = () => { + const reference = this.getReference(); + let data = []; + + for (let i = 0; i < reference.cache.length; i++) { + const count = reference.cache[i].events.length; + + if (count > 0) { + this.buffer.count -= count; + data = data.concat(reference.cache[i].events.splice(0, count)); + } + } + + return data; + }; + + this.emptyCache = number => { + const reference = this.getActiveReference(); + + number = number || reference.state.current; + + reference.state.first = number; + reference.state.last = number; + reference.state.current = number; + reference.cache.splice(0, reference.cache.length); + }; + + this.isOverCapacity = () => { + const reference = this.getActiveReference(); + + return (reference.cache.length - this.page.limit) > 0; + }; + + this.trim = left => { + const reference = this.getActiveReference(); + const excess = reference.cache.length - this.page.limit; + + let ejected; + + if (left) { + ejected = reference.cache.splice(0, excess); + reference.state.first = reference.cache[0].number; + } else { + ejected = reference.cache.splice(-excess); + reference.state.last = reference.cache[reference.cache.length - 1].number; + } + + return ejected.reduce((total, page) => total + page.lines, 0); + }; + + this.isPageBookmarked = number => number >= this.page.bookmark.first && + number <= this.page.bookmark.last; + + this.updateLineCount = (lines, engine) => { + let reference; + + if (engine) { + reference = this.getReference(); + } else { + reference = this.getActiveReference(); + } + + const index = reference.cache.findIndex(item => item.number === reference.state.current); + + reference.cache[index].lines += lines; + }; + + this.isBookmarkPending = () => this.bookmark.pending; + this.isBookmarkSet = () => this.bookmark.set; + + this.setBookmark = () => { + if (this.isBookmarkSet()) { + return; + } + + if (!this.isBookmarkPending()) { + this.bookmark.pending = true; + + return; + } + + this.bookmark.state.first = this.page.state.first; + this.bookmark.state.last = this.page.state.last - 1; + this.bookmark.state.current = this.page.state.current - 1; + this.bookmark.cache = JSON.parse(JSON.stringify(this.page.cache)); + this.bookmark.set = true; + this.bookmark.pending = false; + }; + + this.removeBookmark = () => { + this.bookmark.set = false; + this.bookmark.pending = false; + this.bookmark.cache.splice(0, this.bookmark.cache.length); + this.bookmark.state.first = 0; + this.bookmark.state.last = 0; + this.bookmark.state.current = 0; + }; + + this.next = () => { + const reference = this.getActiveReference(); + const config = this.buildRequestConfig(reference.state.last + 1); + + return this.resource.model.goToPage(config) + .then(data => { + if (!data || !data.results) { + return $q.resolve(); + } + + this.addPage(data.page, [], true); + + return data.results; + }); + }; + + this.previous = () => { + const reference = this.getActiveReference(); + const config = this.buildRequestConfig(reference.state.first - 1); + + return this.resource.model.goToPage(config) + .then(data => { + if (!data || !data.results) { + return $q.resolve(); + } + + this.addPage(data.page, [], false); + + return data.results; + }); + }; + + this.last = () => { + const config = this.buildRequestConfig('last'); + + return this.resource.model.goToPage(config) + .then(data => { + if (!data || !data.results) { + return $q.resolve(); + } + + this.emptyCache(data.page); + this.addPage(data.page, [], true); + + return data.results; + }); + }; + + this.first = () => { + const config = this.buildRequestConfig('first'); + + return this.resource.model.goToPage(config) + .then(data => { + if (!data || !data.results) { + return $q.resolve(); + } + + this.emptyCache(data.page); + this.addPage(data.page, [], false); + + return data.results; + }); + }; + + this.buildRequestConfig = number => ({ + page: number, + related: this.resource.related, + params: { + order_by: 'start_line' + } + }); + + this.getActiveReference = () => (this.isBookmarkSet() ? + this.getReference(true) : this.getReference()); + + this.getReference = (bookmark) => { + if (bookmark) { + return { + bookmark: true, + cache: this.bookmark.cache, + state: this.bookmark.state + }; + } + + return { + bookmark: false, + cache: this.page.cache, + state: this.page.state + }; + }; +} + +JobPageService.$inject = ['$q']; + +export default JobPageService; diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js new file mode 100644 index 0000000000..12ca797b32 --- /dev/null +++ b/awx/ui/client/features/output/render.service.js @@ -0,0 +1,288 @@ +import Ansi from 'ansi-to-html'; +import Entities from 'html-entities'; + +const ELEMENT_TBODY = '#atStdoutResultTable'; +const EVENT_START_TASK = 'playbook_on_task_start'; +const EVENT_START_PLAY = 'playbook_on_play_start'; +const EVENT_STATS_PLAY = 'playbook_on_stats'; + +const EVENT_GROUPS = [ + EVENT_START_TASK, + EVENT_START_PLAY +]; + +const TIME_EVENTS = [ + EVENT_START_TASK, + EVENT_START_PLAY, + EVENT_STATS_PLAY +]; + +const ansi = new Ansi(); +const entities = new Entities.AllHtmlEntities(); + +// https://github.com/chalk/ansi-regex +const pattern = [ + '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)', + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))' +].join('|'); + +const re = new RegExp(pattern); +const hasAnsi = input => re.test(input); + +function JobRenderService ($q, $sce, $window) { + this.init = ({ compile, apply, isStreamActive }) => { + this.parent = null; + this.record = {}; + this.el = $(ELEMENT_TBODY); + this.hooks = { isStreamActive, compile, apply }; + }; + + this.sortByLineNumber = (a, b) => { + if (a.start_line > b.start_line) { + return 1; + } + + if (a.start_line < b.start_line) { + return -1; + } + + return 0; + }; + + this.transformEventGroup = events => { + let lines = 0; + let html = ''; + + events.sort(this.sortByLineNumber); + + events.forEach(event => { + const line = this.transformEvent(event); + + html += line.html; + lines += line.count; + }); + + return { html, lines }; + }; + + this.transformEvent = event => { + if (!event || !event.stdout) { + return { html: '', count: 0 }; + } + + const stdout = this.sanitize(event.stdout); + const lines = stdout.split('\r\n'); + + let count = lines.length; + let ln = event.start_line; + + const current = this.createRecord(ln, lines, event); + + const html = lines.reduce((concat, line, i) => { + ln++; + + const isLastLine = i === lines.length - 1; + + let row = this.createRow(current, ln, line); + + if (current && current.isTruncated && isLastLine) { + row += this.createRow(current); + count++; + } + + return `${concat}${row}`; + }, ''); + + return { html, count }; + }; + + this.createRecord = (ln, lines, event) => { + if (!event.uuid) { + return null; + } + + const info = { + id: event.id, + line: ln + 1, + uuid: event.uuid, + level: event.event_level, + start: event.start_line, + end: event.end_line, + isTruncated: (event.end_line - event.start_line) > lines.length, + isHost: typeof event.host === 'number' + }; + + if (event.parent_uuid) { + info.parents = this.getParentEvents(event.parent_uuid); + } + + if (info.isTruncated) { + info.truncatedAt = event.start_line + lines.length; + } + + if (EVENT_GROUPS.includes(event.event)) { + info.isParent = true; + + if (event.event_level === 1) { + this.parent = event.uuid; + } + + if (event.parent_uuid) { + if (this.record[event.parent_uuid]) { + if (this.record[event.parent_uuid].children && + !this.record[event.parent_uuid].children.includes(event.uuid)) { + this.record[event.parent_uuid].children.push(event.uuid); + } else { + this.record[event.parent_uuid].children = [event.uuid]; + } + } + } + } + + if (TIME_EVENTS.includes(event.event)) { + info.time = this.getTimestamp(event.created); + info.line++; + } + + this.record[event.uuid] = info; + + return info; + }; + + this.createRow = (current, ln, content) => { + let id = ''; + let timestamp = ''; + let tdToggle = ''; + let tdEvent = ''; + let classList = ''; + + content = content || ''; + + if (hasAnsi(content)) { + content = ansi.toHtml(content); + } + + if (current) { + if (!this.hooks.isStreamActive() && current.isParent && current.line === ln) { + id = current.uuid; + tdToggle = ``; + } + + if (current.isHost) { + tdEvent = `${content}`; + } + + if (current.time && current.line === ln) { + timestamp = `${current.time}`; + } + + if (current.parents) { + classList = current.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, ''); + } + } + + if (!tdEvent) { + tdEvent = `${content}`; + } + + if (!tdToggle) { + tdToggle = ''; + } + + if (!ln) { + ln = '...'; + } + + return ` + + ${tdToggle} + ${ln} + ${tdEvent} + ${timestamp} + `; + }; + + this.getTimestamp = created => { + const date = new Date(created); + const hour = date.getHours() < 10 ? `0${date.getHours()}` : date.getHours(); + const minute = date.getMinutes() < 10 ? `0${date.getMinutes()}` : date.getMinutes(); + const second = date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds(); + + return `${hour}:${minute}:${second}`; + }; + + this.getParentEvents = (uuid, list) => { + list = list || []; + + if (this.record[uuid]) { + list.push(uuid); + + if (this.record[uuid].parents) { + list = list.concat(this.record[uuid].parents); + } + } + + return list; + }; + + this.getEvents = () => this.hooks.get(); + + this.insert = (events, insert) => { + const result = this.transformEventGroup(events); + const html = this.trustHtml(result.html); + + return this.requestAnimationFrame(() => insert(html)) + .then(() => this.compile(html)) + .then(() => result.lines); + }; + + this.remove = elements => this.requestAnimationFrame(() => { + elements.remove(); + }); + + this.requestAnimationFrame = fn => $q(resolve => { + $window.requestAnimationFrame(() => { + if (fn) { + fn(); + } + + return resolve(); + }); + }); + + this.compile = html => { + html = $(this.el); + this.hooks.compile(html); + + return this.requestAnimationFrame(); + }; + + this.clear = () => { + const elements = this.el.children(); + return this.remove(elements); + }; + + this.shift = lines => { + const elements = this.el.children().slice(0, lines); + + return this.remove(elements); + }; + + this.pop = lines => { + const elements = this.el.children().slice(-lines); + + return this.remove(elements); + }; + + this.prepend = events => this.insert(events, html => this.el.prepend(html)); + + this.append = events => this.insert(events, html => this.el.append(html)); + + this.trustHtml = html => $sce.getTrustedHtml($sce.trustAsHtml(html)); + + this.sanitize = html => entities.encode(html); +} + +JobRenderService.$inject = ['$q', '$sce', '$window']; + +export default JobRenderService; diff --git a/awx/ui/client/features/output/scroll.service.js b/awx/ui/client/features/output/scroll.service.js new file mode 100644 index 0000000000..a568813ddc --- /dev/null +++ b/awx/ui/client/features/output/scroll.service.js @@ -0,0 +1,167 @@ +const ELEMENT_CONTAINER = '.at-Stdout-container'; +const ELEMENT_TBODY = '#atStdoutResultTable'; +const DELAY = 100; +const THRESHOLD = 0.1; + +function JobScrollService ($q, $timeout) { + this.init = (hooks) => { + this.el = $(ELEMENT_CONTAINER); + this.timer = null; + + this.position = { + previous: 0, + current: 0 + }; + + this.hooks = { + isAtRest: hooks.isAtRest, + next: hooks.next, + previous: hooks.previous + }; + + this.state = { + locked: false, + paused: false, + top: true + }; + + this.el.scroll(this.listen); + }; + + this.listen = () => { + if (this.isPaused()) { + return; + } + + if (this.timer) { + $timeout.cancel(this.timer); + } + + this.timer = $timeout(this.register, DELAY); + }; + + this.register = () => { + this.pause(); + + const current = this.getScrollPosition(); + const downward = current > this.position.previous; + + let promise; + + if (downward && this.isBeyondThreshold(downward, current)) { + promise = this.hooks.next; + } else if (!downward && this.isBeyondThreshold(downward, current)) { + promise = this.hooks.previous; + } + + if (!promise) { + this.setScrollPosition(current); + this.isAtRest(); + this.resume(); + + return $q.resolve(); + } + + return promise() + .then(() => { + this.setScrollPosition(this.getScrollPosition()); + this.isAtRest(); + this.resume(); + }); + }; + + this.isBeyondThreshold = (downward, current) => { + const height = this.getScrollHeight(); + + if (downward) { + current += this.getViewableHeight(); + + if (current >= height || ((height - current) / height) < THRESHOLD) { + return true; + } + } else if (current <= 0 || (current / height) < THRESHOLD) { + return true; + } + + return false; + }; + + this.pageUp = () => { + if (this.isPaused()) { + return; + } + + const top = this.getScrollPosition(); + const height = this.getViewableHeight(); + + this.setScrollPosition(top - height); + }; + + this.pageDown = () => { + if (this.isPaused()) { + return; + } + + const top = this.getScrollPosition(); + const height = this.getViewableHeight(); + + this.setScrollPosition(top + height); + }; + + this.getScrollHeight = () => this.el[0].scrollHeight; + this.getViewableHeight = () => this.el[0].offsetHeight; + this.getScrollPosition = () => this.el[0].scrollTop; + + this.setScrollPosition = position => { + this.position.previous = this.position.current; + this.position.current = position; + this.el[0].scrollTop = position; + this.isAtRest(); + }; + + this.resetScrollPosition = () => { + this.position.previous = 0; + this.position.current = 0; + this.el[0].scrollTop = 0; + this.isAtRest(); + }; + + this.scrollToBottom = () => { + this.setScrollPosition(this.getScrollHeight()); + }; + + this.isAtRest = () => { + if (this.position.current === 0 && !this.state.top) { + this.state.top = true; + this.hooks.isAtRest(true); + } else if (this.position.current > 0 && this.state.top) { + this.state.top = false; + this.hooks.isAtRest(false); + } + }; + + this.resume = () => { + this.state.paused = false; + }; + + this.pause = () => { + this.state.paused = true; + }; + + this.isPaused = () => this.state.paused; + + this.lock = () => { + this.state.locked = true; + }; + + this.unlock = () => { + this.state.locked = false; + }; + + this.isLocked = () => this.state.locked; + this.isMissing = () => $(ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight(); +} + +JobScrollService.$inject = ['$q', '$timeout']; + +export default JobScrollService; diff --git a/awx/ui/client/features/output/search.directive.js b/awx/ui/client/features/output/search.directive.js new file mode 100644 index 0000000000..0a688f92bb --- /dev/null +++ b/awx/ui/client/features/output/search.directive.js @@ -0,0 +1,129 @@ +const templateUrl = require('~features/output/search.partial.html'); + +const searchReloadOptions = { reload: true, inherit: false }; +const searchKeyExamples = ['id:>1', 'task:set', 'created:>=2000-01-01']; +const searchKeyFields = ['changed', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play']; + +const PLACEHOLDER_RUNNING = 'CANNOT SEARCH RUNNING JOB'; +const PLACEHOLDER_DEFAULT = 'SEARCH'; + +let $state; +let status; +let qs; + +let vm; + +function toggleSearchKey () { + vm.key = !vm.key; +} + +function getCurrentQueryset () { + const { job_event_search } = $state.params; // eslint-disable-line camelcase + + return qs.decodeArr(job_event_search); +} + +function getSearchTags (queryset) { + return qs.createSearchTagsFromQueryset(queryset) + .filter(tag => !tag.startsWith('event')) + .filter(tag => !tag.startsWith('-event')) + .filter(tag => !tag.startsWith('page_size')) + .filter(tag => !tag.startsWith('order_by')); +} + +function removeSearchTag (index) { + const searchTerm = vm.tags[index]; + + const currentQueryset = getCurrentQueryset(); + const modifiedQueryset = qs.removeTermsFromQueryset(currentQueryset, searchTerm); + + vm.tags = getSearchTags(modifiedQueryset); + vm.disabled = true; + + $state.params.job_event_search = qs.encodeArr(modifiedQueryset); + $state.transitionTo($state.current, $state.params, searchReloadOptions); +} + +function submitSearch () { + const searchInputQueryset = qs.getSearchInputQueryset(vm.value); + + const currentQueryset = getCurrentQueryset(); + const modifiedQueryset = qs.mergeQueryset(currentQueryset, searchInputQueryset); + + vm.tags = getSearchTags(modifiedQueryset); + vm.disabled = true; + + $state.params.job_event_search = qs.encodeArr(modifiedQueryset); + $state.transitionTo($state.current, $state.params, searchReloadOptions); +} + +function clearSearch () { + vm.tags = []; + vm.disabled = true; + + $state.params.job_event_search = ''; + $state.transitionTo($state.current, $state.params, searchReloadOptions); +} + +function atJobSearchLink (scope, el, attrs, controllers) { + const [atJobSearchController] = controllers; + + atJobSearchController.init(scope); +} + +function AtJobSearchController (_$state_, _status_, _qs_) { + $state = _$state_; + status = _status_; + qs = _qs_; + + vm = this || {}; + + vm.value = ''; + vm.key = false; + vm.rejected = false; + vm.disabled = true; + vm.tags = getSearchTags(getCurrentQueryset()); + + vm.clearSearch = clearSearch; + vm.searchKeyExamples = searchKeyExamples; + vm.searchKeyFields = searchKeyFields; + vm.toggleSearchKey = toggleSearchKey; + vm.removeSearchTag = removeSearchTag; + vm.submitSearch = submitSearch; + + vm.init = scope => { + vm.examples = scope.examples || searchKeyExamples; + vm.fields = scope.fields || searchKeyFields; + vm.placeholder = PLACEHOLDER_DEFAULT; + vm.relatedFields = scope.relatedFields || []; + + scope.$watch(status.isRunning, value => { + vm.disabled = value; + vm.placeholder = value ? PLACEHOLDER_RUNNING : PLACEHOLDER_DEFAULT; + }); + }; +} + +AtJobSearchController.$inject = [ + '$state', + 'JobStatusService', + 'QuerySet', +]; + +function atJobSearch () { + return { + templateUrl, + restrict: 'E', + require: ['atJobSearch'], + controllerAs: 'vm', + link: atJobSearchLink, + controller: AtJobSearchController, + scope: { + examples: '=', + fields: '=', + relatedFields: '=', + }, + }; +} + +export default atJobSearch; diff --git a/awx/ui/client/features/output/search.partial.html b/awx/ui/client/features/output/search.partial.html new file mode 100644 index 0000000000..d7acedc3d4 --- /dev/null +++ b/awx/ui/client/features/output/search.partial.html @@ -0,0 +1,61 @@ + +
+
+ + + + + + +
+
+ +
+
+
{{ tag }}
+
+ +
+
+
CLEAR ALL
+
+ +
+
+
+
+
EXAMPLES:
+ +
+
+
+ FIELDS: + {{ field }}, +
+
+ ADDITIONAL INFORMATION: + For additional information on advanced search search syntax please see the Ansible Tower + documentation. +
+
+
diff --git a/awx/ui/client/features/output/stats.directive.js b/awx/ui/client/features/output/stats.directive.js new file mode 100644 index 0000000000..51fbd89afb --- /dev/null +++ b/awx/ui/client/features/output/stats.directive.js @@ -0,0 +1,76 @@ +const templateUrl = require('~features/output/stats.partial.html'); + +let status; +let strings; + +function createStatsBarTooltip (key, count) { + const label = `${key}`; + const badge = `${count}`; + + return `${label}${badge}`; +} + +function atJobStatsLink (scope, el, attrs, controllers) { + const [atJobStatsController] = controllers; + + atJobStatsController.init(scope); +} + +function AtJobStatsController (_strings_, _status_) { + status = _status_; + strings = _strings_; + + const vm = this || {}; + + vm.tooltips = { + running: strings.get('status.RUNNING'), + unavailable: strings.get('status.UNAVAILABLE'), + }; + + vm.init = scope => { + const { resource } = scope; + + vm.download = resource.model.get('related.stdout'); + + vm.setHostStatusCounts(status.getHostStatusCounts()); + + scope.$watch(status.getPlayCount, value => { vm.plays = value; }); + scope.$watch(status.getTaskCount, value => { vm.tasks = value; }); + scope.$watch(status.getElapsed, value => { vm.elapsed = value; }); + scope.$watch(status.getHostCount, value => { vm.hosts = value; }); + scope.$watch(status.isRunning, value => { vm.running = value; }); + + scope.$watchCollection(status.getHostStatusCounts, vm.setHostStatusCounts); + }; + + vm.setHostStatusCounts = counts => { + Object.keys(counts).forEach(key => { + const count = counts[key]; + const statusBarElement = $(`.HostStatusBar-${key}`); + + statusBarElement.css('flex', `${count} 0 auto`); + + vm.tooltips[key] = createStatsBarTooltip(key, count); + }); + + vm.statsAreAvailable = Boolean(status.getStatsEvent()); + }; +} + +function atJobStats () { + return { + templateUrl, + restrict: 'E', + require: ['atJobStats'], + controllerAs: 'vm', + link: atJobStatsLink, + controller: [ + 'JobStrings', + 'JobStatusService', + AtJobStatsController + ], + scope: { resource: '=', }, + }; +} + +export default atJobStats; diff --git a/awx/ui/client/features/output/stats.partial.html b/awx/ui/client/features/output/stats.partial.html new file mode 100644 index 0000000000..95668d4e2b --- /dev/null +++ b/awx/ui/client/features/output/stats.partial.html @@ -0,0 +1,81 @@ + +
+ plays + ... + {{ vm.plays }} + + tasks + ... + {{ vm.tasks }} + + hosts + ... + {{ vm.hosts }} + + elapsed + ... + + {{ vm.elapsed * 1000 | duration: "hh:mm:ss" }} + + + + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js new file mode 100644 index 0000000000..a3d80dbc01 --- /dev/null +++ b/awx/ui/client/features/output/status.service.js @@ -0,0 +1,149 @@ +const JOB_START = 'playbook_on_start'; +const JOB_END = 'playbook_on_stats'; +const PLAY_START = 'playbook_on_play_start'; +const TASK_START = 'playbook_on_task_start'; +const HOST_STATUS_KEYS = ['dark', 'failures', 'changed', 'ok', 'skipped']; + +let moment; + +function JobStatusService (_moment_) { + moment = _moment_; + + this.init = ({ resource }) => { + this.counter = -1; + + this.created = resource.model.get('created'); + this.job = resource.model.get('id'); + this.project = resource.model.get('project'); + this.elapsed = resource.model.get('elapsed'); + this.started = resource.model.get('started'); + this.finished = resource.model.get('finished'); + this.jobStatus = resource.model.get('status'); + this.projectStatus = resource.model.get('summary_fields.project_update.status'); + + this.playCount = null; + this.taskCount = null; + this.hostCount = null; + this.active = false; + this.hostStatusCounts = {}; + + this.statsEvent = resource.stats; + this.updateStats(); + }; + + this.pushStatusEvent = data => { + const isJobEvent = (this.job === data.unified_job_id); + const isProjectEvent = (this.project && (this.project === data.project_id)); + + if (isJobEvent) { + this.setJobStatus(data.status); + } else if (isProjectEvent) { + this.setProjectStatus(data.status); + } + }; + + this.pushJobEvent = data => { + const isLatest = ((!this.counter) || (data.counter > this.counter)); + + if (!this.active && !(data.event === JOB_END)) { + this.active = true; + this.setJobStatus('running'); + } + + if (isLatest) { + this.counter = data.counter; + this.elapsed = moment(data.created).diff(this.created, 'seconds'); + this.jobStatus = _.get(data, ['summary_fields', 'job', 'status']); + } + + if (data.event === JOB_START) { + this.started = data.created; + } + + if (data.event === PLAY_START) { + this.playCount++; + } + + if (data.event === TASK_START) { + this.taskCount++; + } + + if (data.event === JOB_END) { + this.statsEvent = data; + } + }; + + this.updateHostCounts = () => { + const countedHostNames = []; + + const counts = Object.assign(...HOST_STATUS_KEYS.map(key => ({ [key]: 0 }))); + + HOST_STATUS_KEYS.forEach(key => { + const hostData = _.get(this.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]++; + } + }); + }); + + this.hostCount = countedHostNames.length; + this.hostStatusCounts = counts; + }; + + this.updateStats = () => { + if (!this.statsEvent) { + return; + } + + this.updateHostCounts(); + + this.setFinished(this.statsEvent.created); + this.setJobStatus(this.statsEvent.failed ? 'failed' : 'successful'); + }; + + this.isRunning = () => (Boolean(this.started) && !this.finished) || + (this.jobStatus === 'running') || + (this.jobStatus === 'pending') || + (this.jobStatus === 'waiting'); + + this.getPlayCount = () => this.playCount; + this.getTaskCount = () => this.taskCount; + this.getHostCount = () => this.hostCount; + this.getHostStatusCounts = () => this.hostStatusCounts || {}; + this.getJobStatus = () => this.jobStatus; + this.getProjectStatus = () => this.projectStatus; + this.getElapsed = () => this.elapsed; + this.getStatsEvent = () => this.statsEvent; + this.getStarted = () => this.started; + this.getFinished = () => this.finished; + + this.setJobStatus = status => { + this.jobStatus = status; + }; + + this.setProjectStatus = status => { + this.projectStatus = status; + }; + + this.setFinished = time => { + this.finished = time; + }; + + this.resetCounts = () => { + this.playCount = 0; + this.taskCount = 0; + this.hostCount = 0; + }; +} + +JobStatusService.$inject = [ + 'moment', +]; + +export default JobStatusService; diff --git a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js index d4c9403823..40dcf2907c 100644 --- a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js +++ b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js @@ -41,7 +41,7 @@ function atLaunchTemplateCtrl ( selectedJobTemplate .postLaunch({ id: vm.template.id }) .then(({ data }) => { - $state.go('jobResult', { id: data.job }, { reload: true }); + $state.go('jobz', { id: data.job, type: 'playbook' }, { reload: true }); }); } else { const promptData = { @@ -138,7 +138,7 @@ function atLaunchTemplateCtrl ( id: vm.promptData.template, launchData: jobLaunchData }).then((launchRes) => { - $state.go('jobResult', { id: launchRes.data.job }, { reload: true }); + $state.go('jobz', { id: launchRes.data.job, type: 'playbook' }, { reload: true }); }).catch(createErrorHandler('launch job template', 'POST')); } else if (vm.promptData.templateType === 'workflow_job_template') { workflowTemplate.create().postLaunch({ diff --git a/awx/ui/client/lib/components/modal/modal.directive.js b/awx/ui/client/lib/components/modal/modal.directive.js index 302ff92a03..f3def99885 100644 --- a/awx/ui/client/lib/components/modal/modal.directive.js +++ b/awx/ui/client/lib/components/modal/modal.directive.js @@ -12,7 +12,7 @@ function atModalLink (scope, el, attrs, controllers) { }); } -function AtModalController (eventService, strings) { +function AtModalController ($timeout, eventService, strings) { const vm = this; let overlay; @@ -58,6 +58,7 @@ function AtModalController (eventService, strings) { } AtModalController.$inject = [ + '$timeout', 'EventService', 'ComponentsStrings' ]; diff --git a/awx/ui/client/lib/components/panel/_index.less b/awx/ui/client/lib/components/panel/_index.less index 7e50d8593a..b89faeb405 100644 --- a/awx/ui/client/lib/components/panel/_index.less +++ b/awx/ui/client/lib/components/panel/_index.less @@ -44,3 +44,15 @@ text-align: center; margin-left: 5px; } + +.at-Panel-label { + text-transform: uppercase; + color: @default-interface-txt; + font-size: 12px; + font-weight: normal!important; + width: 30%; + + @media screen and (max-width: @breakpoint-md) { + flex: 2.5 0 auto; + } +} diff --git a/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js b/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js index 1d88abdb8b..3d7d6b09ff 100644 --- a/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js +++ b/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js @@ -114,7 +114,8 @@ function atRelaunchCtrl ( jobObj.postRelaunch(launchParams) .then((launchRes) => { if (!$state.includes('jobs')) { - $state.go('jobResult', { id: launchRes.data.id }, { reload: true }); + const relaunchType = launchRes.data.type === 'job' ? 'playbook' : launchRes.data.type; + $state.go('jobz', { id: launchRes.data.id, type: relaunchType }, { reload: true }); } }); } @@ -162,7 +163,7 @@ function atRelaunchCtrl ( inventorySource.postUpdate(vm.job.inventory_source) .then((postUpdateRes) => { if (!$state.includes('jobs')) { - $state.go('inventorySyncStdout', { id: postUpdateRes.data.id }, { reload: true }); + $state.go('jobz', { id: postUpdateRes.data.id, type: 'inventory' }, { reload: true }); } }); } else { @@ -181,7 +182,7 @@ function atRelaunchCtrl ( project.postUpdate(vm.job.project) .then((postUpdateRes) => { if (!$state.includes('jobs')) { - $state.go('scmUpdateStdout', { id: postUpdateRes.data.id }, { reload: true }); + $state.go('jobz', { id: postUpdateRes.data.id, type: 'project' }, { reload: true }); } }); } else { @@ -217,7 +218,7 @@ function atRelaunchCtrl ( id: vm.job.id }).then((launchRes) => { if (!$state.includes('jobs')) { - $state.go('adHocJobStdout', { id: launchRes.data.id }, { reload: true }); + $state.go('jobz', { id: launchRes.data.id, type: 'command' }, { reload: true }); } }); } @@ -237,7 +238,7 @@ function atRelaunchCtrl ( relaunchData: PromptService.bundlePromptDataForRelaunch(vm.promptData) }).then((launchRes) => { if (!$state.includes('jobs')) { - $state.go('jobResult', { id: launchRes.data.job }, { reload: true }); + $state.go('jobz', { id: launchRes.data.job, type: 'playbook' }, { reload: true }); } }).catch(({ data, status }) => { ProcessErrors($scope, data, status, null, { diff --git a/awx/ui/client/lib/models/AdHocCommand.js b/awx/ui/client/lib/models/AdHocCommand.js index c398219531..7bea2677ac 100644 --- a/awx/ui/client/lib/models/AdHocCommand.js +++ b/awx/ui/client/lib/models/AdHocCommand.js @@ -1,5 +1,5 @@ -let Base; let $http; +let BaseModel; function getRelaunch (params) { const req = { @@ -19,26 +19,31 @@ function postRelaunch (params) { return $http(req); } +function getStats () { + return Promise.resolve(null); +} + function AdHocCommandModel (method, resource, config) { - Base.call(this, 'ad_hoc_commands'); + 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); } -function AdHocCommandModelLoader (BaseModel, _$http_) { - Base = BaseModel; +function AdHocCommandModelLoader (_$http_, _BaseModel_) { $http = _$http_; + BaseModel = _BaseModel_; return AdHocCommandModel; } AdHocCommandModelLoader.$inject = [ + '$http', 'BaseModel', - '$http' ]; export default AdHocCommandModelLoader; diff --git a/awx/ui/client/lib/models/Base.js b/awx/ui/client/lib/models/Base.js index a2c202aa79..912d9a984c 100644 --- a/awx/ui/client/lib/models/Base.js +++ b/awx/ui/client/lib/models/Base.js @@ -104,6 +104,25 @@ function httpGet (config = {}) { if (config.params) { req.params = config.params; + + if (config.params.page_size) { + this.page.size = config.params.page_size; + this.page.current = 1; + + if (config.pageCache) { + this.page.cachedPages = this.page.cachedPages || {}; + this.page.cache = this.page.cache || {}; + this.page.limit = config.pageLimit || false; + + if (!_.has(this.page.cachedPages, 'root')) { + this.page.cachedPages.root = []; + } + + if (!_.has(this.page.cache, 'root')) { + this.page.cache.root = {}; + } + } + } } if (typeof config.resource === 'object') { @@ -118,6 +137,13 @@ function httpGet (config = {}) { .then(res => { this.model.GET = res.data; + if (config.pageCache) { + this.page.cache.root[this.page.current] = res.data.results; + this.page.cachedPages.root.push(this.page.current); + this.page.count = res.data.count; + this.page.last = Math.ceil(res.data.count / this.page.size); + } + return res; }); } @@ -328,24 +354,42 @@ function has (method, keys) { } function extend (method, related, config = {}) { - if (!related) { - related = method; - method = 'GET'; - } else { - method = method.toUpperCase(); + const req = this.parseRequestConfig(method.toUpperCase(), config); + + if (_.get(config, 'params.page_size')) { + this.page.size = config.params.page_size; + this.page.current = 1; + + if (config.pageCache) { + this.page.cachedPages = this.page.cachedPages || {}; + this.page.cache = this.page.cache || {}; + this.page.limit = config.pageLimit || false; + + if (!_.has(this.page.cachedPages, `related.${related}`)) { + _.set(this.page.cachedPages, `related.${related}`, []); + } + + if (!_.has(this.page.cache, `related.${related}`)) { + _.set(this.page.cache, `related.${related}`, []); + } + } } - if (this.has(method, `related.${related}`)) { - const req = { - method, - url: this.get(`related.${related}`) - }; + if (this.has(req.method, `related.${related}`)) { + req.url = this.get(`related.${related}`); Object.assign(req, config); return $http(req) .then(({ data }) => { - this.set(method, `related.${related}`, data); + this.set(req.method, `related.${related}`, data); + + if (config.pageCache) { + this.page.cache.related[related][this.page.current] = data.results; + this.page.cachedPages.related[related].push(this.page.current); + this.page.count = data.count; + this.page.last = Math.ceil(data.count / this.page.size); + } return this; }); @@ -354,6 +398,97 @@ function extend (method, related, config = {}) { return Promise.reject(new Error(`No related property, ${related}, exists`)); } +function goToPage (config) { + const params = config.params || {}; + const { page } = config; + + let url; + let key; + let pageNumber; + let pageCache; + let pagesInCache; + + if (config.related) { + url = `${this.endpoint}${config.related}/`; + key = `related.${config.related}`; + } else { + url = this.endpoint; + key = 'root'; + } + + params.page_size = this.page.size; + + if (page === 'next') { + pageNumber = this.page.current + 1; + } else if (page === 'previous') { + pageNumber = this.page.current - 1; + } else if (page === 'first') { + pageNumber = 1; + } else if (page === 'last') { + pageNumber = this.page.last; + } else { + pageNumber = page; + } + + if (pageNumber < 1 || pageNumber > this.page.last) { + return Promise.resolve(null); + } + + this.page.current = pageNumber; + + if (this.page.cache) { + pageCache = _.get(this.page.cache, key); + pagesInCache = _.get(this.page.cachedPages, key); + + if (_.has(pageCache, pageNumber)) { + return Promise.resolve({ + results: pageCache[pageNumber], + page: pageNumber + }); + } + } + + params.page_size = this.page.size; + params.page = pageNumber; + + const req = { + method: 'GET', + url, + params + }; + + return $http(req) + .then(({ data }) => { + if (pageCache) { + pageCache[pageNumber] = data.results; + pagesInCache.push(pageNumber); + + if (pagesInCache.length > this.page.limit) { + const pageToDelete = pagesInCache.shift(); + + delete pageCache[pageToDelete]; + } + } + + return { + results: data.results, + page: pageNumber + }; + }); +} + +function next (config = {}) { + config.page = 'next'; + + return this.goToPage(config); +} + +function prev (config = {}) { + config.page = 'previous'; + + return this.goToPage(config); +} + function normalizePath (resource) { const version = '/api/v2/'; @@ -463,6 +598,10 @@ function create (method, resource, config) { return this; } + if (req.resource) { + this.setEndpoint(req.resource); + } + this.promise = this.request(req); if (req.graft) { @@ -473,6 +612,14 @@ function create (method, resource, config) { .then(() => this); } +function setEndpoint (resource) { + if (Array.isArray(resource)) { + this.endpoint = `${this.path}${resource[0]}/`; + } else { + this.endpoint = `${this.path}${resource}/`; + } +} + function parseRequestConfig (method, resource, config) { if (!method) { return null; @@ -525,19 +672,23 @@ function BaseModel (resource, settings) { this.create = create; this.find = find; this.get = get; + this.goToPage = goToPage; this.graft = graft; this.has = has; this.isEditable = isEditable; this.isCacheable = isCacheable; this.isCreatable = isCreatable; this.match = match; + this.next = next; this.normalizePath = normalizePath; this.options = options; this.parseRequestConfig = parseRequestConfig; + this.prev = prev; this.request = request; this.requestWithCache = requestWithCache; this.search = search; this.set = set; + this.setEndpoint = setEndpoint; this.unset = unset; this.extend = extend; this.copy = copy; @@ -552,6 +703,7 @@ function BaseModel (resource, settings) { delete: httpDelete.bind(this) }; + this.page = {}; this.model = {}; this.path = this.normalizePath(resource); this.label = strings.get(`${resource}.LABEL`); diff --git a/awx/ui/client/lib/models/InventoryUpdate.js b/awx/ui/client/lib/models/InventoryUpdate.js new file mode 100644 index 0000000000..668a05459d --- /dev/null +++ b/awx/ui/client/lib/models/InventoryUpdate.js @@ -0,0 +1,27 @@ +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); +} + +function InventoryUpdateModelLoader (_BaseModel_) { + BaseModel = _BaseModel_; + + return InventoryUpdateModel; +} + +InventoryUpdateModelLoader.$inject = [ + 'BaseModel' +]; + +export default InventoryUpdateModelLoader; diff --git a/awx/ui/client/lib/models/Job.js b/awx/ui/client/lib/models/Job.js index 7d87f82330..7dd58482a5 100644 --- a/awx/ui/client/lib/models/Job.js +++ b/awx/ui/client/lib/models/Job.js @@ -1,5 +1,5 @@ -let Base; let $http; +let BaseModel; function getRelaunch (params) { const req = { @@ -23,26 +23,53 @@ 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) { - Base.call(this, 'jobs'); + 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_) { - Base = 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/JobEvent.js b/awx/ui/client/lib/models/JobEvent.js new file mode 100644 index 0000000000..1c71ba9c54 --- /dev/null +++ b/awx/ui/client/lib/models/JobEvent.js @@ -0,0 +1,19 @@ +let BaseModel; + +function JobEventModel (method, resource, config) { + BaseModel.call(this, 'job_events'); + + this.Constructor = JobEventModel; + + return this.create(method, resource, config); +} + +function JobEventModelLoader (_BaseModel_) { + BaseModel = _BaseModel_; + + return JobEventModel; +} + +JobEventModel.$inject = ['BaseModel']; + +export default JobEventModelLoader; diff --git a/awx/ui/client/lib/models/ProjectUpdate.js b/awx/ui/client/lib/models/ProjectUpdate.js new file mode 100644 index 0000000000..df038283cf --- /dev/null +++ b/awx/ui/client/lib/models/ProjectUpdate.js @@ -0,0 +1,51 @@ +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_; + BaseModel = _BaseModel_; + + return ProjectUpdateModel; +} + +ProjectUpdateModelLoader.$inject = [ + '$http', + 'BaseModel' +]; + +export default ProjectUpdateModelLoader; diff --git a/awx/ui/client/lib/models/SystemJob.js b/awx/ui/client/lib/models/SystemJob.js new file mode 100644 index 0000000000..1f1f1c5ee3 --- /dev/null +++ b/awx/ui/client/lib/models/SystemJob.js @@ -0,0 +1,25 @@ +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); +} + +function SystemJobModelLoader (_BaseModel_) { + BaseModel = _BaseModel_; + + return SystemJobModel; +} + +SystemJobModelLoader.$inject = ['BaseModel']; + +export default SystemJobModelLoader; diff --git a/awx/ui/client/lib/models/index.js b/awx/ui/client/lib/models/index.js index fb902fb91c..d50c825e22 100644 --- a/awx/ui/client/lib/models/index.js +++ b/awx/ui/client/lib/models/index.js @@ -11,20 +11,25 @@ import InstanceGroup from '~models/InstanceGroup'; import Inventory from '~models/Inventory'; import InventoryScript from '~models/InventoryScript'; import InventorySource from '~models/InventorySource'; +import InventoryUpdate from '~models/InventoryUpdate'; import Job from '~models/Job'; +import JobEvent from '~models/JobEvent'; import JobTemplate from '~models/JobTemplate'; import Me from '~models/Me'; -import ModelsStrings from '~models/models.strings'; import NotificationTemplate from '~models/NotificationTemplate'; import Organization from '~models/Organization'; import Project from '~models/Project'; import Schedule from '~models/Schedule'; +import ProjectUpdate from '~models/ProjectUpdate'; +import SystemJob from '~models/SystemJob'; import UnifiedJobTemplate from '~models/UnifiedJobTemplate'; import WorkflowJob from '~models/WorkflowJob'; import WorkflowJobTemplate from '~models/WorkflowJobTemplate'; import WorkflowJobTemplateNode from '~models/WorkflowJobTemplateNode'; import UnifiedJob from '~models/UnifiedJob'; +import ModelsStrings from '~models/models.strings'; + const MODULE_NAME = 'at.lib.models'; angular @@ -42,18 +47,22 @@ angular .service('InventoryModel', Inventory) .service('InventoryScriptModel', InventoryScript) .service('InventorySourceModel', InventorySource) + .service('InventoryUpdateModel', InventoryUpdate) + .service('JobEventModel', JobEvent) .service('JobModel', Job) .service('JobTemplateModel', JobTemplate) .service('MeModel', Me) - .service('ModelsStrings', ModelsStrings) .service('NotificationTemplate', NotificationTemplate) .service('OrganizationModel', Organization) .service('ProjectModel', Project) .service('ScheduleModel', Schedule) .service('UnifiedJobModel', UnifiedJob) + .service('ProjectUpdateModel', ProjectUpdate) + .service('SystemJobModel', SystemJob) .service('UnifiedJobTemplateModel', UnifiedJobTemplate) .service('WorkflowJobModel', WorkflowJob) .service('WorkflowJobTemplateModel', WorkflowJobTemplate) - .service('WorkflowJobTemplateNodeModel', WorkflowJobTemplateNode); + .service('WorkflowJobTemplateNodeModel', WorkflowJobTemplateNode) + .service('ModelsStrings', ModelsStrings); export default MODULE_NAME; diff --git a/awx/ui/client/lib/theme/_global.less b/awx/ui/client/lib/theme/_global.less index 6995b224a5..2b9f67fdec 100644 --- a/awx/ui/client/lib/theme/_global.less +++ b/awx/ui/client/lib/theme/_global.less @@ -50,6 +50,17 @@ font-size: @at-font-size-body; } +.at-ButtonIcon-noborder { + padding: 4px @at-padding-button-horizontal; + font-size: @at-font-size-body; + .at-mixin-Button(); + .at-mixin-ButtonHollow( + 'at-color-default', + 'at-color-default', + 'at-color-button-text-default' + ); +} + .at-Button--expand { width: 100%; } diff --git a/awx/ui/client/lib/theme/_utility.less b/awx/ui/client/lib/theme/_utility.less index 1f47a481f3..b3663b74a3 100644 --- a/awx/ui/client/lib/theme/_utility.less +++ b/awx/ui/client/lib/theme/_utility.less @@ -16,3 +16,15 @@ margin-left: 0; margin-right: 0; } + +.at-u-clear { + clear: both; +} + +.at-u-noBorder { + border: none; +} + +.at-u-floatRight { + float: right +} diff --git a/awx/ui/client/lib/theme/index.less b/awx/ui/client/lib/theme/index.less index 9a9f564840..a0f7738272 100644 --- a/awx/ui/client/lib/theme/index.less +++ b/awx/ui/client/lib/theme/index.less @@ -81,10 +81,6 @@ @import '../../src/inventories-hosts/inventories/inventories.block.less'; @import '../../src/inventories-hosts/shared/associate-groups/associate-groups.block.less'; @import '../../src/inventories-hosts/shared/associate-hosts/associate-hosts.block.less'; -@import '../../src/job-results/host-event/host-event.block.less'; -@import '../../src/job-results/host-status-bar/host-status-bar.block.less'; -@import '../../src/job-results/job-results-stdout/job-results-stdout.block.less'; -@import '../../src/job-results/job-results.block.less'; @import '../../src/job-submission/job-submission.block.less'; @import '../../src/license/license.block.less'; @import '../../src/login/loginModal/thirdPartySignOn/thirdPartySignOn.block.less'; @@ -117,7 +113,7 @@ @import '../../src/shared/text-label'; @import '../../src/shared/upgrade/upgrade.block.less'; @import '../../src/smart-status/smart-status.block.less'; -@import '../../src/standard-out/standard-out.block.less'; +@import '../../src/workflow-results/standard-out.block.less'; @import '../../src/system-tracking/date-picker/date-picker.block.less'; @import '../../src/system-tracking/fact-data-table/fact-data-table.block.less'; @import '../../src/system-tracking/fact-module-filter.block.less'; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index ab64107955..3567df4ef5 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -19,7 +19,6 @@ import credentialTypes from './credential-types/main'; import organizations from './organizations/main'; import managementJobs from './management-jobs/main'; import workflowResults from './workflow-results/main'; -import jobResults from './job-results/main'; import jobSubmission from './job-submission/main'; import notifications from './notifications/main'; import about from './about/main'; @@ -30,7 +29,6 @@ import configuration from './configuration/main'; import home from './home/main'; import login from './login/main'; import activityStream from './activity-stream/main'; -import standardOut from './standard-out/main'; import Templates from './templates/main'; import teams from './teams/main'; import users from './users/main'; @@ -67,7 +65,6 @@ angular 'gettext', 'Timezones', 'lrInfiniteScroll', - about.name, access.name, license.name, @@ -86,10 +83,8 @@ angular login.name, activityStream.name, workflowResults.name, - jobResults.name, jobSubmission.name, notifications.name, - standardOut.name, Templates.name, portalMode.name, teams.name, @@ -242,23 +237,6 @@ angular $rootScope.crumbCache = []; $transitions.onStart({}, function(trans) { - // Remove any lingering intervals - // except on jobResults.* states - var jobResultStates = [ - 'jobResult', - 'jobResult.host-summary', - 'jobResult.host-event.details', - 'jobResult.host-event.json', - 'jobResult.host-events', - 'jobResult.host-event.stdout' - ]; - if ($rootScope.jobResultInterval && !_.includes(jobResultStates, trans.to().name) ) { - window.clearInterval($rootScope.jobResultInterval); - } - if ($rootScope.jobStdOutInterval && !_.includes(jobResultStates, trans.to().name) ) { - window.clearInterval($rootScope.jobStdOutInterval); - } - $rootScope.flashMessage = null; $('#form-modal2 .modal-body').empty(); diff --git a/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js b/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js index 4d8be63ccd..93a2147834 100644 --- a/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js +++ b/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js @@ -28,8 +28,17 @@ export default function createList(list) { // detailsUrl, status, name, time scope.jobs = _.map(list, function(job){ + + let detailsUrl; + + if (job.type === 'workflow_job') { + detailsUrl = `/#/workflows/${job.id}`; + } else { + detailsUrl = `/#/jobz/playbook/${job.id}`; + } + return { - detailsUrl: job.type && job.type === 'workflow_job' ? job.url.replace(/api\/v\d+\/workflow_jobs/, "#/workflows") : job.url.replace(/api\/v\d+/, "#"), + detailsUrl, status: job.status, name: job.name, id: job.id, diff --git a/awx/ui/client/src/inventories-hosts/inventories/adhoc/adhoc.controller.js b/awx/ui/client/src/inventories-hosts/inventories/adhoc/adhoc.controller.js index 5aa5938714..e5e020fc6d 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/adhoc/adhoc.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/adhoc/adhoc.controller.js @@ -241,7 +241,7 @@ function adhocController($q, $scope, $stateParams, Rest.post(data) .then(({data}) => { Wait('stop'); - $state.go('adHocJobStdout', {id: data.id}); + $state.go('jobz', {id: data.id, type: 'command'}); }) .catch(({data, status}) => { ProcessErrors($scope, data, status, adhocForm, { diff --git a/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.controller.js b/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.controller.js index 495c201646..7b2e28e8aa 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.controller.js @@ -23,7 +23,7 @@ export default [ '$scope', 'Empty', 'Wait', 'GetBasePath', 'Rest', 'ProcessError }; $scope.viewJob = function(jobId) { - $state.go('jobResult', {id: jobId}); + $state.go('jobz', { id: jobId, type: 'playbook' }); }; } diff --git a/awx/ui/client/src/inventories-hosts/inventories/list/source-summary-popover/source-summary-popover.controller.js b/awx/ui/client/src/inventories-hosts/inventories/list/source-summary-popover/source-summary-popover.controller.js index 661d122c88..725069044e 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/list/source-summary-popover/source-summary-popover.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/list/source-summary-popover/source-summary-popover.controller.js @@ -20,7 +20,7 @@ export default [ '$scope', 'Wait', 'Empty', 'Rest', 'ProcessErrors', '$state', $scope.viewJob = function(url) { // Pull the id out of the URL var id = url.replace(/^\//, '').split('/')[3]; - $state.go('inventorySyncStdout', {id: id}); + $state.go('jobz', { id, type: 'inventory' } ); }; } diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/sources/factories/view-update-status.factory.js b/awx/ui/client/src/inventories-hosts/inventories/related/sources/factories/view-update-status.factory.js index e7820f79c8..c889517506 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/sources/factories/view-update-status.factory.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/sources/factories/view-update-status.factory.js @@ -17,7 +17,7 @@ export default // Get the ID from the correct summary field var update_id = (data.summary_fields.current_update) ? data.summary_fields.current_update.id : data.summary_fields.last_update.id; - $state.go('inventorySyncStdout', {id: update_id}); + $state.go('jobz', { id: update_id, type: 'inventory' }); }) .catch(({data, status}) => { ProcessErrors(scope, data, status, null, { hdr: 'Error!', diff --git a/awx/ui/client/src/job-results/event-queue.service.js b/awx/ui/client/src/job-results/event-queue.service.js deleted file mode 100644 index c97861ac6b..0000000000 --- a/awx/ui/client/src/job-results/event-queue.service.js +++ /dev/null @@ -1,77 +0,0 @@ -/************************************************* -* Copyright (c) 2016 Ansible, Inc. -* -* All Rights Reserved -*************************************************/ - -export default ['jobResultsService', 'parseStdoutService', function(jobResultsService, parseStdoutService){ - var val = {}; - - val = { - populateDefers: {}, - queue: {}, - // munge the raw event from the backend into the event_queue's format - munge: function(event) { - // basic data needed in the munged event - var mungedEvent = { - counter: event.counter, - id: event.id, - processed: false, - name: event.event_name, - changes: [] - }; - - // the interface for grabbing standard out is generalized and - // present across many types of events, so go ahead and check for - // updates to it - if (event.stdout) { - mungedEvent.stdout = parseStdoutService.parseStdout(event); - mungedEvent.start_line = event.start_line + 1; - mungedEvent.end_line = event.end_line + 1; - mungedEvent.actual_end_line = parseStdoutService.actualEndLine(event) + 1; - mungedEvent.changes.push('stdout'); - } - - // for different types of events, you need different types of data - if (event.event_name === 'playbook_on_start') { - mungedEvent.startTime = event.modified; - mungedEvent.changes.push('startTime'); - } if (event.event_name === 'playbook_on_stats') { - // get the data for populating the host status bar - mungedEvent.count = jobResultsService - .getCountsFromStatsEvent(event.event_data); - mungedEvent.finishedTime = event.modified; - mungedEvent.changes.push('count'); - mungedEvent.changes.push('countFinished'); - mungedEvent.changes.push('finishedTime'); - } - return mungedEvent; - }, - // reinitializes the event queue value for the job results page - initialize: function() { - val.queue = {}; - val.populateDefers = {}; - }, - // populates the event queue - populate: function(event) { - if (event) { - val.queue[event.counter] = val.munge(event); - - if (!val.queue[event.counter].processed) { - return val.munge(event); - } else { - return {}; - } - } else { - return {}; - } - }, - // the event has been processed in the view and should be marked as - // completed in the queue - markProcessed: function(event) { - val.queue[event.counter].processed = true; - } - }; - - return val; -}]; diff --git a/awx/ui/client/src/job-results/host-event/host-event.controller.js b/awx/ui/client/src/job-results/host-event/host-event.controller.js deleted file mode 100644 index 330e581189..0000000000 --- a/awx/ui/client/src/job-results/host-event/host-event.controller.js +++ /dev/null @@ -1,143 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - - - export default - ['$scope', '$state', 'jobResultsService', 'hostEvent', - function($scope, $state, jobResultsService, hostEvent){ - - $scope.processEventStatus = jobResultsService.processEventStatus; - $scope.processResults = function(value){ - if (typeof value === 'object'){return false;} - else {return true;} - }; - - var initCodeMirror = function(el, data, mode){ - var container = document.getElementById(el); - var editor = CodeMirror.fromTextArea(container, { // jshint ignore:line - lineNumbers: true, - mode: mode, - readOnly: true, - scrollbarStyle: null - }); - editor.setSize("100%", 200); - editor.getDoc().setValue(data); - }; - /*ignore jslint end*/ - $scope.isActiveState = function(name){ - return $state.current.name === name; - }; - - $scope.getActiveHostIndex = function(){ - var result = $scope.hostResults.filter(function( obj ) { - return obj.id === $scope.event.id; - }); - return $scope.hostResults.indexOf(result[0]); - }; - - $scope.closeHostEvent = function() { - // Unbind the listener so it doesn't fire when we close the modal via navigation - $('#HostEvent').off('hidden.bs.modal'); - $('#HostEvent').modal('hide'); - $state.go('jobResult'); - }; - - var init = function(){ - hostEvent.event_name = hostEvent.event; - $scope.event = _.cloneDeep(hostEvent); - - // grab standard out & standard error if present from the host - // event's "res" object, for things like Ansible modules. Small - // wrinkle in this implementation is that the stdout/stderr tabs - // should be shown if the `res` object has stdout/stderr keys, even - // if they're a blank string. The presence of these keys is - // potentially significant to a user. - try{ - $scope.module_name = hostEvent.event_data.task_action || "No result found"; - $scope.stdout = hostEvent.event_data.res.stdout ? hostEvent.event_data.res.stdout : hostEvent.event_data.res.stdout === "" ? " " : undefined; - $scope.stderr = hostEvent.event_data.res.stderr ? hostEvent.event_data.res.stderr : hostEvent.event_data.res.stderr === "" ? " " : undefined; - $scope.json = hostEvent.event_data.res; - } - catch(err){ - // do nothing, no stdout/stderr for this module - } - if($scope.module_name === "debug" && - _.has(hostEvent.event_data, "res.result.stdout")){ - $scope.stdout = hostEvent.event_data.res.result.stdout; - } - if($scope.module_name === "yum" && - _.has(hostEvent.event_data, "res.results") && - _.isArray(hostEvent.event_data.res.results)){ - $scope.stdout = hostEvent.event_data.res.results[0]; - } - // instantiate Codemirror - // try/catch pattern prevents the abstract-state controller from complaining about element being null - if ($state.current.name === 'jobResult.host-event.json'){ - try{ - if(_.has(hostEvent.event_data, "res")){ - initCodeMirror('HostEvent-codemirror', JSON.stringify($scope.json, null, 4), {name: "javascript", json: true}); - resize(); - } - else{ - $scope.no_json = true; - } - - } - catch(err){ - // element with id HostEvent-codemirror is not the view controlled by this instance of HostEventController - } - } - else if ($state.current.name === 'jobResult.host-event.stdout'){ - try{ - resize(); - } - catch(err){ - // element with id HostEvent-codemirror is not the view controlled by this instance of HostEventController - } - } - else if ($state.current.name === 'jobResult.host-event.stderr'){ - try{ - resize(); - } - catch(err){ - // element with id HostEvent-codemirror is not the view controlled by this instance of HostEventController - } - } - $('#HostEvent').modal('show'); - $('.modal-content').resizable({ - minHeight: 523, - minWidth: 600 - }); - $('.modal-dialog').draggable({ - cancel: '.HostEvent-view--container' - }); - - function resize(){ - if ($state.current.name === 'jobResult.host-event.json'){ - let editor = $('.CodeMirror')[0].CodeMirror; - let height = $('.modal-dialog').height() - $('.HostEvent-header').height() - $('.HostEvent-details').height() - $('.HostEvent-nav').height() - $('.HostEvent-controls').height() - 120; - editor.setSize("100%", height); - } - else if($state.current.name === 'jobResult.host-event.stdout' || $state.current.name === 'jobResult.host-event.stderr'){ - let height = $('.modal-dialog').height() - $('.HostEvent-header').height() - $('.HostEvent-details').height() - $('.HostEvent-nav').height() - $('.HostEvent-controls').height() - 120; - $(".HostEvent-stdout").width("100%"); - $(".HostEvent-stdout").height(height); - $(".HostEvent-stdoutContainer").height(height); - $(".HostEvent-numberColumnPreload").height(height); - } - - } - - $('.modal-dialog').on('resize', function(){ - resize(); - }); - - $('#HostEvent').on('hidden.bs.modal', function () { - $scope.closeHostEvent(); - }); - }; - init(); - }]; diff --git a/awx/ui/client/src/job-results/host-event/host-event.route.js b/awx/ui/client/src/job-results/host-event/host-event.route.js deleted file mode 100644 index e0a32ad7e4..0000000000 --- a/awx/ui/client/src/job-results/host-event/host-event.route.js +++ /dev/null @@ -1,66 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import { templateUrl } from '../../shared/template-url/template-url.factory'; - -var hostEventModal = { - name: 'jobResult.host-event', - url: '/host-event/:eventId', - controller: 'HostEventController', - templateUrl: templateUrl('job-results/host-event/host-event-modal'), - 'abstract': false, - ncyBreadcrumb: { - skip: true - }, - resolve: { - hostEvent: ['jobResultsService', '$stateParams', function(jobResultsService, $stateParams) { - return jobResultsService.getRelatedJobEvents($stateParams.id, { - id: $stateParams.eventId - }).then((response) => response.data.results[0]); - }] - }, - onExit: function() { - // close the modal - // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" - $('#HostEvent').modal('hide'); - // hacky way to handle user browsing away via URL bar - $('.modal-backdrop').remove(); - $('body').removeClass('modal-open'); - } -}; - -var hostEventJson = { - name: 'jobResult.host-event.json', - url: '/json', - controller: 'HostEventController', - templateUrl: templateUrl('job-results/host-event/host-event-codemirror'), - ncyBreadcrumb: { - skip: true - }, -}; - -var hostEventStdout = { - name: 'jobResult.host-event.stdout', - url: '/stdout', - controller: 'HostEventController', - templateUrl: templateUrl('job-results/host-event/host-event-stdout'), - ncyBreadcrumb: { - skip: true - }, -}; - -var hostEventStderr = { - name: 'jobResult.host-event.stderr', - url: '/stderr', - controller: 'HostEventController', - templateUrl: templateUrl('job-results/host-event/host-event-stderr'), - ncyBreadcrumb: { - skip: true - }, -}; - - -export { hostEventJson, hostEventModal, hostEventStdout, hostEventStderr }; diff --git a/awx/ui/client/src/job-results/host-event/main.js b/awx/ui/client/src/job-results/host-event/main.js deleted file mode 100644 index 76832b45e5..0000000000 --- a/awx/ui/client/src/job-results/host-event/main.js +++ /dev/null @@ -1,20 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - - import {hostEventModal, - hostEventJson, hostEventStdout, hostEventStderr} from './host-event.route'; - import controller from './host-event.controller'; - - export default - angular.module('jobResults.hostEvent', []) - .controller('HostEventController', controller) - - .run(['$stateExtender', function($stateExtender){ - $stateExtender.addState(hostEventModal); - $stateExtender.addState(hostEventJson); - $stateExtender.addState(hostEventStdout); - $stateExtender.addState(hostEventStderr); - }]); diff --git a/awx/ui/client/src/job-results/host-status-bar/host-status-bar.block.less b/awx/ui/client/src/job-results/host-status-bar/host-status-bar.block.less deleted file mode 100644 index 5eb948eaad..0000000000 --- a/awx/ui/client/src/job-results/host-status-bar/host-status-bar.block.less +++ /dev/null @@ -1,79 +0,0 @@ -.HostStatusBar { - display: flex; - flex: 0 0 auto; - width: 100%; - margin-top: 10px; -} - -.HostStatusBar-ok, -.HostStatusBar-changed, -.HostStatusBar-unreachable, -.HostStatusBar-failures, -.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-failures { - 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--failures { - background-color: @default-err; - -} diff --git a/awx/ui/client/src/job-results/host-status-bar/host-status-bar.directive.js b/awx/ui/client/src/job-results/host-status-bar/host-status-bar.directive.js deleted file mode 100644 index 5a8b5b3206..0000000000 --- a/awx/ui/client/src/job-results/host-status-bar/host-status-bar.directive.js +++ /dev/null @@ -1,47 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -// import hostStatusBarController from './host-status-bar.controller'; -export default [ 'templateUrl', - function(templateUrl) { - return { - scope: true, - templateUrl: templateUrl('job-results/host-status-bar/host-status-bar'), - restrict: 'E', - // controller: standardOutLogController, - link: function(scope) { - // as count is changed by event data coming in, - // update the host status bar - var toDestroy = scope.$watch('count', function(val) { - if (val) { - Object.keys(val).forEach(key => { - // reposition the hosts status bar by setting - // the various flex values to the count of - // those hosts - $(`.HostStatusBar-${key}`) - .css('flex', `${val[key]} 0 auto`); - - // set the tooltip to give how many hosts of - // each type - if (val[key] > 0) { - scope[`${key}CountTip`] = `${key}${val[key]}`; - } - }); - - // if there are any hosts that have finished, don't - // show default grey bar - scope.hasCount = (Object - .keys(val) - .filter(key => (val[key] > 0)).length > 0); - } - }); - - scope.$on('$destroy', function(){ - toDestroy(); - }); - } - }; -}]; diff --git a/awx/ui/client/src/job-results/host-status-bar/host-status-bar.partial.html b/awx/ui/client/src/job-results/host-status-bar/host-status-bar.partial.html deleted file mode 100644 index a8854b6d09..0000000000 --- a/awx/ui/client/src/job-results/host-status-bar/host-status-bar.partial.html +++ /dev/null @@ -1,30 +0,0 @@ -
-
-
-
-
-
-
-
-
diff --git a/awx/ui/client/src/job-results/host-status-bar/main.js b/awx/ui/client/src/job-results/host-status-bar/main.js deleted file mode 100644 index 2b17a2e414..0000000000 --- a/awx/ui/client/src/job-results/host-status-bar/main.js +++ /dev/null @@ -1,11 +0,0 @@ -/************************************************* - * Copyright (c) 2015 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import hostStatusBar from './host-status-bar.directive'; - -export default - angular.module('hostStatusBarDirective', []) - .directive('hostStatusBar', hostStatusBar); diff --git a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.block.less b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.block.less deleted file mode 100644 index d186677cd1..0000000000 --- a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.block.less +++ /dev/null @@ -1,271 +0,0 @@ -@breakpoint-md: 1200px; - -.JobResultsStdOut { - height: auto; - width: 100%; - display: flex; - flex-direction: column; - align-items: stretch; -} - -.JobResultsStdOut-toolbar { - flex: initial; - display: flex; - border-bottom: 0px; - border-radius: 5px; - border-bottom-left-radius: 0px; - border-bottom-right-radius: 0px; - user-select: none; - -moz-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; -} - -.JobResultsStdOut-toolbarNumberColumn { - background-color: @default-list-header-bg; - color: @b7grey; - flex: initial; - display: flex; - justify-content: space-between; - width: 70px; - padding-bottom: 10px; - padding-left: 8px; - padding-right: 8px; - padding-top: 10px; - border-top-left-radius: 5px; - z-index: 1; - border-right: 1px solid @d7grey; -} - -.JobResultsStdOut-expandAllButton { - height: 18px; - width: 18px; - padding-left: 4px; - padding-top: 1px; - border-radius: 50%; - background-color: @default-bg; - font-size: 12px; - cursor: pointer; - color: #848992; -} - -.JobResultsStdOut-expandAllButton:hover .JobResultsStdOut-expandAllIcon, -.JobResultsStdOut-expandAllIcon:hover { - color: @default-data-txt; -} - -.JobResultsStdOut-toolbarStdoutColumn { - white-space: normal; - flex: 1; - display: flex; - justify-content: flex-end; - padding-right: 10px; - background-color: @default-secondary-bg; - border-top-right-radius: 5px; -} - -.JobResultsStdOut-followButton { - cursor: pointer; - width: 18px; - height: 18px; - width: 18px; - padding: 1px 0 0 4px; - border-radius: 50%; - margin-top: 10px; - font-size: 11px; - background-color: @default-icon; - color: @default-bg; -} - -.JobResultsStdOut-followIcon { - color: @default-bg; -} - -.JobResultsStdOut-followButton:hover { - background-color: @default-data-txt; -} - -.JobResultsStdOut-followButton.is-engaged { - background-color: @default-link; - color: @default-bg; -} - -.JobResultsStdOut-followButton.is-engaged .JobResultsStdOut-followIcon { - color: @default-bg; -} - -.JobResultsStdOut-followButton.is-engaged:hover { - background-color: @default-icon; -} - -.JobResultsStdOut-followButton.is-engaged:hover .JobResultsStdOut-followIcon, -.JobResultsStdOut-followButton.is-engaged .JobResultsStdOut-followIcon:hover { - color: @default-border; -} - -.JobResultsStdOut-stdoutContainer { - flex: 1; - position: relative; - background-color: @default-secondary-bg; - overflow-y: scroll; - overflow-x: hidden; -} - -.JobResultsStdOut-numberColumnPreload { - background-color: @default-list-header-bg; - border-right: 1px solid @d7grey; - position: absolute; - height: 100%; - width: 70px; -} - -.JobResultsStdOut-aLineOfStdOut { - display: flex; - font-family: Monaco, Menlo, Consolas, "Courier New", monospace; -} - -.JobResultsStdOut-lineExpander { - text-align: left; - padding-left: 11px; - margin-right: auto; -} - -.JobResultsStdOut-lineExpanderIcon { - font-size: 19px; - cursor: pointer; -} - -.JobResultsStdOut-lineExpanderIcon:hover { - color: @default-data-txt; -} - -.JobResultsStdOut-lineNumberColumn { - display: flex; - background-color: @default-list-header-bg; - text-align: right; - padding-right: 10px; - padding-top: 2px; - padding-bottom: 2px; - width: 75px; - flex: 1 0 70px; - user-select: none; - -moz-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; - z-index: 1; - border-right: 1px solid @d7grey; - color: @default-icon; -} - -.JobResultsStdOut-stdoutColumn { - padding-left: 20px; - padding-right: 20px; - padding-top: 2px; - padding-bottom: 2px; - color: @default-interface-txt; - display: inline-block; - white-space: pre-wrap; - word-break: break-all; - width:100%; - background-color: @default-secondary-bg; -} - -.JobResultsStdOut-stdoutColumn--tooMany { - font-weight: bold; - text-transform: uppercase; - color: @default-err; -} - -.JobResultsStdOut-stdoutColumn--clickable { - cursor: pointer; -} - -.JobResultsStdOut-aLineOfStdOut:hover, -.JobResultsStdOut-aLineOfStdOut:hover .JobResultsStdOut-lineNumberColumn, -.JobResultsStdOut-aLineOfStdOut:hover .JobResultsStdOut-stdoutColumn { - background-color: @default-bg; -} - -.JobResultsStdOut-aLineOfStdOut:hover .JobResultsStdOut-lineNumberColumn { - border-right: 1px solid @default-bg; -} - -.JobResultsStdOut-footer { - height: 20px; - border-bottom-right-radius: 5px; - border-bottom-left-radius: 5px; - background-color: @default-secondary-bg; - border-top: 0px; - border-radius: 5px; - border-top-left-radius: 0px; - border-top-right-radius: 0px; - overflow: hidden; - margin-top: -1px; -} - -.JobResultsStdOut-footerNumberColumn { - background-color: @default-list-header-bg; - width: 70px; - height: 100%; - border-right: 1px solid @d7grey; -} - -.JobResultsStdOut-followAnchor { - height: 0px; -} - -.JobResultsStdOut-toTop { - color: @default-icon; - cursor: pointer; - font-family: monaco; - font-size: 10px; - margin-right: 20px; - text-align: right; - display: flex; - - span { - margin-left: auto; - } -} - -.JobResultsStdOut-toTop--numberColumn { - background: @default-list-header-bg; - height: 40px; - width: 70px; - border-right: 1px solid #D7D7D7; -} - -.JobResultsStdOut-toTop:hover { - color: @default-data-txt; -} - -.JobResultsStdOut-cappedLine { - color: @b7grey; - font-style: italic; -} - -@media (max-width: @breakpoint-md) { - .JobResultsStdOut-numberColumnPreload { - display: none; - } - - .JobResultsStdOut-topAnchor { - position: static; - width: 100%; - top: -20px; - margin-top: -250px; - margin-bottom: 250px; - } - - .JobResultsStdOut-followAnchor { - height: 0px; - } - - .JobResultsStdOut-stdoutContainer { - overflow-y: auto; - } - - .JobResultsStdOut-lineAnchor { - display: none !important; - } -} diff --git a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.directive.js b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.directive.js deleted file mode 100644 index 14a34a607a..0000000000 --- a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.directive.js +++ /dev/null @@ -1,415 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -// import hostStatusBarController from './host-status-bar.controller'; -export default [ 'templateUrl', '$timeout', '$location', '$anchorScroll', - function(templateUrl, $timeout, $location, $anchorScroll) { - return { - scope: false, - templateUrl: templateUrl('job-results/job-results-stdout/job-results-stdout'), - restrict: 'E', - link: function(scope, element) { - var toDestroy = [], - resizer, - scrollWatcher; - - scope.$on('$destroy', function(){ - $(window).off("resize", resizer); - $(window).off("scroll", scrollWatcher); - $(".JobResultsStdOut-stdoutContainer").off('scroll', - scrollWatcher); - toDestroy.forEach(closureFunc => closureFunc()); - }); - - scope.stdoutContainerAvailable.resolve("container available"); - // utility function used to find the top visible line and - // parent header in the pane - // - // note that while this function is called when in mobile width - // the line anchor is not displayed in the css so calls - // to lineAnchor do nothing - var findTopLines = function() { - var $container = $('.JobResultsStdOut-stdoutContainer'); - - // get the first visible line's head element - var getHeadElement = function (line) { - var lineHasHeaderClass = !!(line - .hasClass("header_play") || - line.hasClass("header_task")); - var lineClassList; - var lineUUIDClass; - - if (lineHasHeaderClass) { - // find head element when the first visible - // line is a header - - lineClassList = line.attr("class") - .split(" "); - - // get the header class w task uuid... - lineUUIDClass = lineClassList - .filter(n => n - .indexOf("header_task_") > -1)[0]; - - // ...if that doesn't exist get the one - // w play uuid - if (!lineUUIDClass) { - lineUUIDClass = lineClassList - .filter(n => n. - indexOf("header_play_") > -1)[0]; - } - - // get the header line (not their might - // be more than one, so get the one with - // the actual header class) - // - // TODO it might be better in this case to just - // return `line` (less jumping with a cowsay - // case) - return $(".actual_header." + - lineUUIDClass); - } else { - // find head element when the first visible - // line is not a header - - lineClassList = line.attr("class") - .split(" "); - - // get the class w task uuid... - lineUUIDClass = lineClassList - .filter(n => n - .indexOf("task_") > -1)[0]; - - // ...if that doesn't exist get the one - // w play uuid - if (!lineUUIDClass) { - lineUUIDClass = lineClassList - .filter(n => n - .indexOf("play_") > -1)[0]; - } - - // get the header line (not their might - // be more than one, so get the one with - // the actual header class) - return $(".actual_header.header_" + - lineUUIDClass); - } - }; - - var visItem, - parentItem; - - // iterate through each line of standard out - $container.find('.JobResultsStdOut-aLineOfStdOut:visible') - .each( function () { - var $this = $(this); - - // check to see if the line is the first visible - // line in the viewport... - if ($this.position().top >= 0) { - - // ...if it is, return the line number - // for this line - visItem = parseInt($($this - .children()[0]) - .text()); - - // as well as the line number for it's - // closest parent header line - var $head = getHeadElement($this); - parentItem = parseInt($($head - .children()[0]) - .text()); - - // stop iterating over the standard out - // lines once the first one has been - // found - - $this = null; - return false; - } - - $this = null; - }); - - $container = null; - - return { - visLine: visItem, - parentVisLine: parentItem - }; - }; - - // find if window is initially mobile or desktop width - if (window.innerWidth <= 1200) { - scope.isMobile = true; - } else { - scope.isMobile = false; - } - - resizer = function() { - // and update the isMobile var accordingly - if (window.innerWidth <= 1200 && !scope.isMobile) { - scope.isMobile = true; - } else if (window.innerWidth > 1200 & scope.isMobile) { - scope.isMobile = false; - } - }; - // watch changes to the window size - $(window).resize(resizer); - - var lastScrollTop; - - var initScrollTop = function() { - lastScrollTop = 0; - }; - scrollWatcher = function() { - var st = $(this).scrollTop(); - var netScroll = st + $(this).innerHeight(); - var fullHeight; - - if (st < lastScrollTop){ - // user up scrolled, so disengage follow - scope.followEngaged = false; - } - - if (scope.isMobile) { - // for mobile the height is the body of the entire - // page - fullHeight = $("body").height(); - } else { - // for desktop the height is the body of the - // stdoutContainer, minus the "^ TOP" indicator - fullHeight = $(this)[0].scrollHeight - 25; - } - - if(netScroll >= fullHeight) { - // user scrolled all the way to bottom, so engage - // follow - scope.followEngaged = true; - } - - // pane is now overflowed, show top indicator. - if (st > 0) { - scope.stdoutOverflowed = true; - } - - lastScrollTop = st; - - st = null; - netScroll = null; - fullHeight = null; - }; - - // update scroll watchers when isMobile changes based on - // window resize - toDestroy.push(scope.$watch('isMobile', function(val) { - if (val === true) { - // make sure ^ TOP always shown for mobile - scope.stdoutOverflowed = true; - - // unbind scroll watcher on standard out container - $(".JobResultsStdOut-stdoutContainer") - .unbind('scroll'); - - // init scroll watcher on window - initScrollTop(); - $(window).on('scroll', scrollWatcher); - - } else if (val === false) { - // unbind scroll watcher on window - $(window).unbind('scroll'); - - // init scroll watcher on standard out container - initScrollTop(); - $(".JobResultsStdOut-stdoutContainer").on('scroll', - scrollWatcher); - } - })); - - // called to scroll to follow anchor - scope.followScroll = function() { - // a double-check to make sure the follow anchor is at - // the bottom of the standard out container - $(".JobResultsStdOut-followAnchor") - .appendTo(".JobResultsStdOut-stdoutContainer"); - - $location.hash('followAnchor'); - $anchorScroll(); - }; - - // called to scroll to top of standard out (when "^ TOP" is - // clicked) - scope.toTop = function() { - $location.hash('topAnchor'); - $anchorScroll(); - }; - - // called to scroll to the current line anchor - // when expand all/collapse all/filtering is engaged - // - // note that while this function can be called when in mobile - // width the line anchor is not displayed in the css so those - // calls do nothing - scope.lineAnchor = function() { - $location.hash('lineAnchor'); - $anchorScroll(); - }; - - // if following becomes active, go ahead and get to the bottom - // of the standard out pane - toDestroy.push(scope.$watch('followEngaged', function(val) { - // scroll to follow point if followEngaged is true - if (val) { - scope.followScroll(); - } - - // set up tooltip changes for not finsihed job - if (!scope.jobFinished) { - if (val) { - scope.followTooltip = "Currently following standard out as it comes in. Click to unfollow."; - } else { - scope.followTooltip = "Click to follow standard out as it comes in."; - } - } - })); - - // follow button ng-click function - scope.followToggleClicked = function() { - if (scope.jobFinished) { - // when the job is finished engage followScroll - scope.followScroll(); - } else { - // when the job is not finished toggle followEngaged - // which is watched above - scope.followEngaged = !scope.followEngaged; - } - }; - - // expand all/collapse all ng-click function - scope.toggleAllStdout = function(type) { - // find the top visible line in the container currently, - // as well as the header parent of that line - var topLines = findTopLines(); - - if (type === 'expand') { - // for expand prepend the lineAnchor to the visible - // line - $(".line_num_" + topLines.visLine) - .prepend($("#lineAnchor")); - } else { - // for collapse prepent the lineAnchor to the - // visible line's parent header - $(".line_num_" + topLines.parentVisLine) - .prepend($("#lineAnchor")); - } - - var expandClass; - if (type === 'expand') { - // for expand all, you'll need to find all the - // collapsed headers to expand them - expandClass = "fa-caret-right"; - } else { - // for collapse all, you'll need to find all the - // expanded headers to collapse them - expandClass = "fa-caret-down"; - } - - // find all applicable task headers that need to be - // toggled - element.find(".expanderizer--task."+expandClass) - .each((i, val) => { - // and trigger their expansion/collapsing - $timeout(function(){ - // TODO change to a direct call of the - // toggleLine function - angular.element(val).trigger('click'); - // TODO only call lineAnchor for those - // that are above the first visible line - scope.lineAnchor(); - }); - }); - - // find all applicable play headers that need to be - // toggled - element.find(".expanderizer--play."+expandClass) - .each((i, val) => { - // for collapse all, only collapse play - // headers that do not have children task - // headers - if(angular.element("." + - angular.element(val).attr("data-uuid")) - .find(".expanderizer--task") - .length === 0 || - type !== 'collapse') { - - // trigger their expansion/ - // collapsing - $timeout(function(){ - // TODO change to a direct - // call of the - // toggleLine function - angular.element(val) - .trigger('click'); - // TODO only call lineAnchor - // for those that are above - // the first visible line - scope.lineAnchor(); - }); - } - }); - }; - - // expand/collapse triangle ng-click function - scope.toggleLine = function($event, id) { - // if the section is currently expanded - if ($($event.currentTarget).hasClass("fa-caret-down")) { - // hide all the children lines - $(id).hide(); - - // and change the triangle for the header to collapse - $($event.currentTarget) - .removeClass("fa-caret-down"); - $($event.currentTarget) - .addClass("fa-caret-right"); - } else { - // if the section is currently collapsed - - // show all the children lines - $(id).show(); - - // and change the triangle for the header to expanded - $($event.currentTarget) - .removeClass("fa-caret-right"); - $($event.currentTarget) - .addClass("fa-caret-down"); - - // if the section you expanded is a play - if ($($event.currentTarget) - .hasClass("expanderizer--play")) { - // find all children task headers and - // expand them too - $("." + $($event.currentTarget) - .attr("data-uuid")) - .find(".expanderizer--task") - .each((i, val) => { - if ($(val) - .hasClass("fa-caret-right")) { - $timeout(function(){ - // TODO change to a - // direct call of the - // toggleLine function - angular.element(val) - .trigger('click'); - }); - } - }); - } - } - }; - } - }; -}]; diff --git a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.partial.html b/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.partial.html deleted file mode 100644 index 63ae96d90c..0000000000 --- a/awx/ui/client/src/job-results/job-results-stdout/job-results-stdout.partial.html +++ /dev/null @@ -1,65 +0,0 @@ -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
-
-
-
-
-
- -
-
The standard output is too large to display. Please specify additional filters to narrow the standard out.
-
Too much previous output to display. Showing running standard output.
-
Job details are not available for this job. Please download to view standard out.
-
- -
-
-
-
- -
diff --git a/awx/ui/client/src/job-results/job-results-stdout/main.js b/awx/ui/client/src/job-results/job-results-stdout/main.js deleted file mode 100644 index 5fc583b9b1..0000000000 --- a/awx/ui/client/src/job-results/job-results-stdout/main.js +++ /dev/null @@ -1,11 +0,0 @@ -/************************************************* - * Copyright (c) 2015 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import jobResultsStdOut from './job-results-stdout.directive'; - -export default - angular.module('jobResultStdOutDirective', []) - .directive('jobResultsStandardOut', jobResultsStdOut); diff --git a/awx/ui/client/src/job-results/job-results.block.less b/awx/ui/client/src/job-results/job-results.block.less deleted file mode 100644 index 356aeb5a6a..0000000000 --- a/awx/ui/client/src/job-results/job-results.block.less +++ /dev/null @@ -1,248 +0,0 @@ -@breakpoint-md: 1200px; - -.JobResults { - .OnePlusTwo-container(100%, @breakpoint-md); - - &.fullscreen { - .JobResults-rightSide { - max-width: 100%; - } - } -} - -.JobResults-leftSide { - .OnePlusTwo-left--panel(100%, @breakpoint-md); - max-width: 30%; - height: ~"calc(100vh - 177px)"; - - @media screen and (max-width: @breakpoint-md) { - max-width: 100%; - } -} - -.JobResults-rightSide { - .OnePlusTwo-right--panel(100%, @breakpoint-md); - height: ~"calc(100vh - 177px)"; - - @media (max-width: @breakpoint-md - 1px) { - padding-right: 15px; - } -} - -.JobResults-detailsPanel{ - overflow-y: scroll; -} - -.JobResults-stdoutActionButton--active { - display: none; - visibility: hidden; - flex:none; - width:0px; - padding-right: 0px; -} - -.JobResults-panelHeader { - display: flex; - height: 30px; -} - -.JobResults-panelHeaderText { - color: @default-interface-txt; - flex: 1 0 auto; - font-size: 14px; - font-weight: bold; - margin-right: 10px; - text-transform: uppercase; -} - -.JobResults-panelHeaderButtonActions { - display: flex; -} - -.JobResults-resultRow { - width: 100%; - display: flex; - padding-bottom: 10px; - flex-wrap: wrap; -} - -.JobResults-resultRow--variables { - flex-direction: column; - - #cm-variables-container { - width: 100%; - } -} - -.JobResults-resultRowLabel { - text-transform: uppercase; - color: @default-interface-txt; - font-size: 12px; - font-weight: normal!important; - width: 30%; - margin-right: 20px; - - @media screen and (max-width: @breakpoint-md) { - flex: 2.5 0 auto; - } -} - -.JobResults-resultRowLabel--fullWidth { - width: 100%; - margin-right: 0px; -} - -.JobResults-resultRowText { - width: ~"calc(70% - 20px)"; - flex: 1 0 auto; - text-transform: none; - word-wrap: break-word; -} - -.JobResults-resultRowText--fullWidth { - width: 100%; -} - -.JobResults-expandArrow { - color: #D7D7D7; - font-size: 14px; - font-weight: bold; - margin-right: 10px; - text-transform: uppercase; - margin-left: 10px; -} - -.JobResults-resultRowText--instanceGroup { - display: flex; -} - -.JobResults-isolatedBadge { - align-items: center; - background-color: @default-list-header-bg; - border-radius: 5px; - color: @default-stdout-txt; - display: flex; - font-size: 10px; - height: 16px; - margin: 3px 0 0 10px; - padding: 0 10px; - text-transform: uppercase; -} - -.JobResults-statusResultIcon { - padding-left: 0px; - padding-right: 10px; -} - -.JobResults-badgeRow { - display: flex; - align-items: center; - margin-right: 5px; -} - -.JobResults-badgeTitle{ - color: @default-interface-txt; - font-size: 14px; - margin-right: 10px; - font-weight: normal; - text-transform: uppercase; - margin-left: 20px; -} - -@media (max-width: @breakpoint-md) { - .JobResults-detailsPanel { - overflow-y: auto; - } - - .JobResults-rightSide { - height: inherit; - } -} - -.JobResults-timeBadge { - float:right; - font-size: 11px; - font-weight: normal; - padding: 1px 10px; - height: 14px; - margin: 3px 15px; - width: 80px; - background-color: @default-bg; - border-radius: 5px; - color: @default-interface-txt; - margin-right: -5px; -} - -.JobResults-panelRight { - display: flex; - flex-direction: column; -} - -.JobResults-panelRight .SmartSearch-bar { - width: 100%; -} - -.JobResults-panelRightTitle{ - flex-wrap: wrap; -} - -.JobResults-panelRightTitleText{ - word-wrap: break-word; - word-break: break-all; - max-width: 100%; -} - -.JobResults-badgeAndActionRow{ - display:flex; - flex: 1 0 auto; - justify-content: flex-end; - flex-wrap: wrap; - max-width: 100%; -} - -.StandardOut-panelHeader { - flex: initial; -} - -.StandardOut-panelHeader--jobIsRunning { - margin-bottom: 20px; -} - -host-status-bar { - flex: initial; - margin-bottom: 20px; -} - -smart-search { - flex: initial; -} - -job-results-standard-out { - flex: 1; - flex-basis: auto; - height: ~"calc(100% - 800px)"; - display: flex; - border: 1px solid @d7grey; - border-radius: 5px; - margin-top: 20px; -} -@media screen and (max-width: @breakpoint-md) { - job-results-standard-out { - height: auto; - } -} - -.JobResults-extraVarsHelp { - margin-left: 10px; - color: @default-icon; -} - -.JobResults-seeMoreLess { - color: #337AB7; - margin: 4px 0px; - text-transform: uppercase; - padding: 2px 0px; - cursor: pointer; - border-radius: 5px; - font-size: 11px; -} diff --git a/awx/ui/client/src/job-results/job-results.controller.js b/awx/ui/client/src/job-results/job-results.controller.js deleted file mode 100644 index e7e8f2c716..0000000000 --- a/awx/ui/client/src/job-results/job-results.controller.js +++ /dev/null @@ -1,784 +0,0 @@ -export default ['jobData', 'jobDataOptions', 'jobLabels', 'jobFinished', 'count', '$scope', 'ParseTypeChange', - 'ParseVariableString', 'jobResultsService', 'eventQueue', '$compile', '$log', 'Dataset', '$q', - 'QuerySet', '$rootScope', 'moment', '$stateParams', 'i18n', 'fieldChoices', 'fieldLabels', - 'workflowResultsService', 'statusSocket', 'GetBasePath', '$state', 'jobExtraCredentials', -function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTypeChange, - ParseVariableString, jobResultsService, eventQueue, $compile, $log, Dataset, $q, - QuerySet, $rootScope, moment, $stateParams, i18n, fieldChoices, fieldLabels, - workflowResultsService, statusSocket, GetBasePath, $state, jobExtraCredentials) { - - var toDestroy = []; - var cancelRequests = false; - var runTimeElapsedTimer = null; - - // download stdout tooltip text - $scope.standardOutTooltip = i18n._('Download Output'); - - // stdout full screen toggle tooltip text - $scope.toggleStdoutFullscreenTooltip = i18n._("Expand Output"); - - // this allows you to manage the timing of rest-call based events as - // filters are updated. see processPage for more info - var currentContext = 1; - $scope.firstCounterFromSocket = -1; - - $scope.explanationLimit = 150; - - // if the user enters the page mid-run, reset the search to include a param - // to only grab events less than the first counter from the websocket events - toDestroy.push($scope.$watch('firstCounterFromSocket', function(counter) { - if (counter > -1) { - // make it so that the search include a counter less than the - // first counter from the socket - let params = _.cloneDeep($stateParams.job_event_search); - params.counter__lte = "" + counter; - - Dataset = QuerySet.search(jobData.related.job_events, - params); - - Dataset.then(function(actualDataset) { - $scope.job_event_dataset = actualDataset.data; - }); - } - })); - - // used for tag search - $scope.job_event_dataset = Dataset.data; - - // used for tag search - $scope.list = { - basePath: jobData.related.job_events, - name: 'job_events' - }; - - // used for tag search - $scope.job_events = $scope.job_event_dataset.results; - - $scope.jobExtraCredentials = jobExtraCredentials; - - var getLinks = function() { - var getLink = function(key) { - if(key === 'schedule') { - if($scope.job.related.schedule) { - return '/#/templates/job_template/' + $scope.job.job_template + '/schedules' + $scope.job.related.schedule.split(/api\/v\d+\/schedules/)[1]; - } - else { - return null; - } - } - else if(key === 'inventory') { - if($scope.job.summary_fields.inventory && $scope.job.summary_fields.inventory.id) { - if($scope.job.summary_fields.inventory.kind && $scope.job.summary_fields.inventory.kind === 'smart') { - return '/#/inventories/smart/' + $scope.job.summary_fields.inventory.id; - } - else { - return '/#/inventories/inventory/' + $scope.job.summary_fields.inventory.id; - } - } - else { - return null; - } - } - else { - if ($scope.job.related[key]) { - return '/#/' + $scope.job.related[key] - .split(/api\/v\d+\//)[1]; - } else { - return null; - } - } - }; - - $scope.created_by_link = getLink('created_by'); - $scope.scheduled_by_link = getLink('schedule'); - $scope.inventory_link = getLink('inventory'); - $scope.project_link = getLink('project'); - $scope.machine_credential_link = getLink('credential'); - $scope.cloud_credential_link = getLink('cloud_credential'); - $scope.network_credential_link = getLink('network_credential'); - $scope.vault_credential_link = getLink('vault_credential'); - $scope.schedule_link = getLink('schedule'); - }; - - // uses options to set scope variables to their readable string - // value - var getLabels = function() { - var getLabel = function(key) { - if ($scope.jobOptions && $scope.jobOptions[key]) { - return $scope.jobOptions[key].choices - .filter(val => val[0] === $scope.job[key]) - .map(val => val[1])[0]; - } else { - return null; - } - }; - - $scope.type_label = getLabel('job_type'); - $scope.verbosity_label = getLabel('verbosity'); - }; - - var getTotalHostCount = function(count) { - return Object - .keys(count).reduce((acc, i) => acc += count[i], 0); - }; - - // put initially resolved request data on scope - $scope.job = jobData; - $scope.jobOptions = jobDataOptions.actions.GET; - $scope.labels = jobLabels; - $scope.jobFinished = jobFinished; - - // update label in left pane and tooltip in right pane when the job_status - // changes - toDestroy.push($scope.$watch('job_status', function(status) { - if (status) { - $scope.status_label = $scope.jobOptions.status.choices - .filter(val => val[0] === status) - .map(val => val[1])[0]; - $scope.status_tooltip = "Job " + $scope.status_label; - } - })); - - $scope.previousTaskFailed = false; - - toDestroy.push($scope.$watch('job.job_explanation', function(explanation) { - if (explanation && explanation.split(":")[0] === "Previous Task Failed") { - $scope.previousTaskFailed = true; - - var taskObj = JSON.parse(explanation.substring(explanation.split(":")[0].length + 1)); - // return a promise from the options request with the permission type choices (including adhoc) as a param - var fieldChoice = fieldChoices({ - $scope: $scope, - url: GetBasePath('unified_jobs'), - field: 'type' - }); - - // manipulate the choices from the options request to be set on - // scope and be usable by the list form - fieldChoice.then(function (choices) { - choices = - fieldLabels({ - choices: choices - }); - $scope.explanation_fail_type = choices[taskObj.job_type]; - $scope.explanation_fail_name = taskObj.job_name; - $scope.explanation_fail_id = taskObj.job_id; - $scope.task_detail = $scope.explanation_fail_type + " failed for " + $scope.explanation_fail_name + " with ID " + $scope.explanation_fail_id + "."; - }); - } else { - $scope.previousTaskFailed = false; - } - })); - - - // update the job_status value. Use the cached rootScope value if there - // is one. This is a workaround when the rest call for the jobData is - // made before some socket events come in for the job status - if ($rootScope['lastSocketStatus' + jobData.id]) { - $scope.job_status = $rootScope['lastSocketStatus' + jobData.id]; - delete $rootScope['lastSocketStatus' + jobData.id]; - } else { - $scope.job_status = jobData.status; - } - - // turn related api browser routes into front end routes - getLinks(); - - // the links below can't be set in getLinks because the - // links on the UI don't directly match the corresponding URL - // on the API browser - if(jobData.summary_fields && jobData.summary_fields.job_template && - jobData.summary_fields.job_template.id){ - $scope.job_template_link = `/#/templates/job_template/${$scope.job.summary_fields.job_template.id}`; - } - if(jobData.summary_fields && jobData.summary_fields.project_update && - jobData.summary_fields.project_update.status){ - $scope.project_status = jobData.summary_fields.project_update.status; - } - if(jobData.summary_fields && jobData.summary_fields.project_update && - jobData.summary_fields.project_update.id){ - $scope.project_update_link = `/#/scm_update/${jobData.summary_fields.project_update.id}`; - } - if(jobData.summary_fields && jobData.summary_fields.source_workflow_job && - jobData.summary_fields.source_workflow_job.id){ - $scope.workflow_result_link = `/#/workflows/${jobData.summary_fields.source_workflow_job.id}`; - } - if(jobData.result_traceback) { - $scope.job.result_traceback = jobData.result_traceback.trim().split('\n').join('
'); - } - - // use options labels to manipulate display of details - getLabels(); - - // set up a read only code mirror for extra vars - $scope.variables = ParseVariableString($scope.job.extra_vars); - $scope.parseType = 'yaml'; - ParseTypeChange({ scope: $scope, - field_id: 'pre-formatted-variables', - readOnly: true }); - - // Click binding for the expand/collapse button on the standard out log - $scope.stdoutFullScreen = false; - $scope.toggleStdoutFullscreen = function() { - $scope.stdoutFullScreen = !$scope.stdoutFullScreen; - - if ($scope.stdoutFullScreen === true) { - $scope.toggleStdoutFullscreenTooltip = i18n._("Collapse Output"); - } else if ($scope.stdoutFullScreen === false) { - $scope.toggleStdoutFullscreenTooltip = i18n._("Expand Output"); - } - }; - - $scope.deleteJob = function() { - jobResultsService.deleteJob($scope.job); - }; - - $scope.cancelJob = function() { - jobResultsService.cancelJob($scope.job); - }; - - $scope.lessLabels = false; - $scope.toggleLessLabels = function() { - if (!$scope.lessLabels) { - $('#job-results-labels').slideUp(200); - $scope.lessLabels = true; - } - else { - $('#job-results-labels').slideDown(200); - $scope.lessLabels = false; - } - }; - - // get initial count from resolve - $scope.count = count.val; - $scope.hostCount = getTotalHostCount(count.val); - $scope.countFinished = count.countFinished; - - // if the job is still running engage following of the last line in the - // standard out pane - $scope.followEngaged = !$scope.jobFinished; - - // follow button for completed job should specify that the - // button will jump to the bottom of the standard out pane, - // not follow lines as they come in - if ($scope.jobFinished) { - $scope.followTooltip = i18n._("Jump to last line of standard out."); - } else { - $scope.followTooltip = i18n._("Currently following standard out as it comes in. Click to unfollow."); - } - - $scope.events = {}; - - function updateJobElapsedTimer(time) { - $scope.job.elapsed = time; - } - - // For elapsed time while a job is running, compute the differnce in seconds, - // from the time the job started until now. Moment() returns the current - // time as a moment object. - if ($scope.job.started !== null && $scope.job.status === 'running') { - runTimeElapsedTimer = workflowResultsService.createOneSecondTimer($scope.job.started, updateJobElapsedTimer); - } - - // EVENT STUFF BELOW - var linesInPane = []; - - function addToLinesInPane(event) { - var arr = _.range(event.start_line, event.actual_end_line); - linesInPane = linesInPane.concat(arr); - linesInPane = linesInPane.sort(function(a, b) { - return a - b; - }); - } - - function appendToBottom (event){ - // if we get here then the event type was either a - // header line, recap line, or one of the additional - // event types, so we append it to the bottom. - // These are the event types for captured - // stdout not directly related to playbook or runner - // events: - // (0, 'debug', _('Debug'), False), - // (0, 'verbose', _('Verbose'), False), - // (0, 'deprecated', _('Deprecated'), False), - // (0, 'warning', _('Warning'), False), - // (0, 'system_warning', _('System Warning'), False), - // (0, 'error', _('Error'), True), - angular - .element(".JobResultsStdOut-stdoutContainer") - .append($compile(event - .stdout)($scope.events[event - .counter])); - } - - function putInCorrectPlace(event) { - if (linesInPane.length) { - for (var i = linesInPane.length - 1; i >= 0; i--) { - if (event.start_line > linesInPane[i]) { - $(`.line_num_${linesInPane[i]}`) - .after($compile(event - .stdout)($scope.events[event - .counter])); - i = -1; - } - } - } else { - appendToBottom(event); - } - } - - // This is where the async updates to the UI actually happen. - // Flow is event queue munging in the service -> $scope setting in here - var processEvent = function(event, context) { - // only care about filter context checking when the event comes - // as a rest call - if (context && context !== currentContext) { - return; - } - // put the event in the queue - var mungedEvent = eventQueue.populate(event); - - // make changes to ui based on the event returned from the queue - if (mungedEvent.changes) { - mungedEvent.changes.forEach(change => { - // we've got a change we need to make to the UI! - // update the necessary scope and make the change - if (change === 'startTime' && !$scope.job.start) { - $scope.job.start = mungedEvent.startTime; - } - - if (change === 'count' && !$scope.countFinished) { - // for all events that affect the host count, - // update the status bar as well as the host - // count badge - $scope.count = mungedEvent.count; - $scope.hostCount = getTotalHostCount(mungedEvent - .count); - } - - if (change === 'finishedTime' && !$scope.job.finished) { - $scope.job.finished = mungedEvent.finishedTime; - $scope.jobFinished = true; - $scope.followTooltip = i18n._("Jump to last line of standard out."); - if ($scope.followEngaged) { - if (!$scope.followScroll) { - $scope.followScroll = function() { - $log.error("follow scroll undefined, standard out directive not loaded yet?"); - }; - } - $scope.followScroll(); - } - } - - if (change === 'countFinished') { - // the playbook_on_stats event actually lets - // us know that we don't need to iteratively - // look at event to update the host counts - // any more. - $scope.countFinished = true; - } - - if(change === 'stdout'){ - if (!$scope.events[mungedEvent.counter]) { - // line hasn't been put in the pane yet - - // create new child scope - $scope.events[mungedEvent.counter] = $scope.$new(); - $scope.events[mungedEvent.counter] - .event = mungedEvent; - - // let's see if we have a specific place to put it in - // the pane - let $prevElem = $(`.next_is_${mungedEvent.start_line}`); - if ($prevElem && $prevElem.length) { - // if so, put it there - $(`.next_is_${mungedEvent.start_line}`) - .after($compile(mungedEvent - .stdout)($scope.events[mungedEvent - .counter])); - addToLinesInPane(mungedEvent); - } else { - var putIn; - var classList = $("div", - "
"+mungedEvent.stdout+"
") - .attr("class").split(" "); - if (classList - .filter(v => v.indexOf("task_") > -1) - .length) { - putIn = classList - .filter(v => v.indexOf("task_") > -1)[0]; - } else if(classList - .filter(v => v.indexOf("play_") > -1) - .length) { - putIn = classList - .filter(v => v.indexOf("play_") > -1)[0]; - } - - var putAfter; - var isDup = false; - - if ($(".header_" + putIn + ",." + putIn).length === 0) { - putInCorrectPlace(mungedEvent); - addToLinesInPane(mungedEvent); - } else { - $(".header_" + putIn + ",." + putIn) - .each((i, v) => { - if (angular.element(v).scope() - .event.start_line < mungedEvent - .start_line) { - putAfter = v; - } else if (angular.element(v).scope() - .event.start_line === mungedEvent - .start_line) { - isDup = true; - return false; - } else if (angular.element(v).scope() - .event.start_line > mungedEvent - .start_line) { - return false; - } else { - appendToBottom(mungedEvent); - addToLinesInPane(mungedEvent); - } - }); - } - - if (!isDup && putAfter) { - addToLinesInPane(mungedEvent); - $(putAfter).after($compile(mungedEvent - .stdout)($scope.events[mungedEvent - .counter])); - } - - - classList = null; - putIn = null; - } - - // delete ref to the elem because it might leak scope - // if you don't - $prevElem = null; - } - - // move the followAnchor to the bottom of the - // container - $(".JobResultsStdOut-followAnchor") - .appendTo(".JobResultsStdOut-stdoutContainer"); - } - }); - - // the changes have been processed in the ui, mark it in the - // queue - eventQueue.markProcessed(event); - } - }; - - $scope.stdoutContainerAvailable = $q.defer(); - $scope.hasSkeleton = $q.defer(); - - eventQueue.initialize(); - - $scope.playCount = 0; - $scope.taskCount = 0; - - - // used to show a message to just download for old jobs - // remove in 3.2.0 - $scope.isOld = 0; - $scope.showLegacyJobErrorMessage = false; - - toDestroy.push($scope.$watch('isOld', function (val) { - if (val >= 2) { - $scope.showLegacyJobErrorMessage = true; - } - })); - - // get header and recap lines - var skeletonPlayCount = 0; - var skeletonTaskCount = 0; - var getSkeleton = function(url) { - jobResultsService.getEvents(url) - .then(events => { - events.results.forEach(event => { - if (event.start_line === 0 && event.end_line === 0) { - $scope.isOld++; - } - // get the name in the same format as the data - // coming over the websocket - event.event_name = event.event; - delete event.event; - - // increment play and task count - if (event.event_name === "playbook_on_play_start") { - skeletonPlayCount++; - } else if (event.event_name === "playbook_on_task_start") { - skeletonTaskCount++; - } - - processEvent(event); - }); - if (events.next) { - getSkeleton(events.next); - } else { - // after the skeleton requests have completed, - // put the play and task count into the dom - $scope.playCount = skeletonPlayCount; - $scope.taskCount = skeletonTaskCount; - $scope.hasSkeleton.resolve("skeleton resolved"); - } - }); - }; - - $scope.stdoutContainerAvailable.promise.then(() => { - getSkeleton(jobData.related.job_events + "?order_by=start_line&or__event__in=playbook_on_start,playbook_on_play_start,playbook_on_task_start,playbook_on_stats"); - }); - - var getEvents; - - var processPage = function(events, context) { - // currentContext is the context of the filter when this request - // to processPage was made - // - // currentContext is the context of the filter currently - // - // if they are not the same, make sure to stop process events/ - // making rest calls for next pages/etc. (you can see context is - // also passed into getEvents and processEvent and similar checks - // exist in these functions) - // - // also, if the page doesn't contain results (i.e.: the response - // returns an error), don't process the page - if (context !== currentContext || events === undefined || - events.results === undefined) { - return; - } - - events.results.forEach(event => { - // get the name in the same format as the data - // coming over the websocket - event.event_name = event.event; - delete event.event; - - processEvent(event, context); - }); - if (events.next && !cancelRequests) { - getEvents(events.next, context); - } else { - // put those paused events into the pane - $scope.gotPreviouslyRanEvents.resolve(""); - } - }; - - // grab non-header recap lines - getEvents = function(url, context) { - if (context !== currentContext) { - return; - } - - jobResultsService.getEvents(url) - .then(events => { - processPage(events, context); - }); - }; - - // grab non-header recap lines - toDestroy.push($scope.$watch('job_event_dataset', function(val) { - if (val) { - eventQueue.initialize(); - - Object.keys($scope.events) - .forEach(v => { - // dont destroy scope events for skeleton lines - let name = $scope.events[v].event.name; - - if (!(name === "playbook_on_play_start" || - name === "playbook_on_task_start" || - name === "playbook_on_stats")) { - $scope.events[v].$destroy(); - $scope.events[v] = null; - delete $scope.events[v]; - } - }); - - // pause websocket events from coming in to the pane - $scope.gotPreviouslyRanEvents = $q.defer(); - currentContext += 1; - - let context = currentContext; - - $( ".JobResultsStdOut-aLineOfStdOut.not_skeleton" ).remove(); - $scope.hasSkeleton.promise.then(() => { - if (val.count > parseInt(val.maxEvents)) { - $(".header_task").hide(); - $(".header_play").hide(); - $scope.standardOutTooltip = '
' + - i18n._('The output is too large to display. Please download.') + - '
' + - '
' + - '' + - '' + - '' + - '' + - '
' + - '
'; - - if ($scope.job_status === "successful" || - $scope.job_status === "failed" || - $scope.job_status === "error" || - $scope.job_status === "canceled") { - $scope.tooManyEvents = true; - $scope.tooManyPastEvents = false; - } else { - $scope.tooManyPastEvents = true; - $scope.tooManyEvents = false; - $scope.gotPreviouslyRanEvents.resolve(""); - } - } else { - $(".header_task").show(); - $(".header_play").show(); - $scope.tooManyEvents = false; - $scope.tooManyPastEvents = false; - processPage(val, context); - } - }); - } - })); - - var buffer = []; - - var processBuffer = function() { - var follow = function() { - // if follow is engaged, - // scroll down to the followAnchor - if ($scope.followEngaged) { - if (!$scope.followScroll) { - $scope.followScroll = function() { - $log.error("follow scroll undefined, standard out directive not loaded yet?"); - }; - } - $scope.followScroll(); - } - }; - - for (let i = 0; i < 4; i++) { - processEvent(buffer[i]); - buffer.splice(i, 1); - } - - follow(); - }; - - var bufferInterval; - - // Processing of job_events messages from the websocket - toDestroy.push($scope.$on(`ws-job_events-${$scope.job.id}`, function(e, data) { - if (!bufferInterval) { - bufferInterval = setInterval(function(){ - processBuffer(); - }, 500); - } - - // use the lowest counter coming over the socket to retrigger pull data - // to only be for stuff lower than that id - // - // only do this for entering the jobs page mid-run (thus the - // data.counter is 1 conditional - if (data.counter === 1) { - $scope.firstCounterFromSocket = -2; - } - - if ($scope.firstCounterFromSocket !== -2 && - $scope.firstCounterFromSocket === -1 || - data.counter < $scope.firstCounterFromSocket) { - $scope.firstCounterFromSocket = data.counter; - } - - $q.all([$scope.gotPreviouslyRanEvents.promise, - $scope.hasSkeleton.promise]).then(() => { - // put the line in the - // standard out pane (and increment play and task - // count if applicable) - if (data.event_name === "playbook_on_play_start") { - $scope.playCount++; - } else if (data.event_name === "playbook_on_task_start") { - $scope.taskCount++; - } - buffer.push(data); - }); - })); - - // get previously set up socket messages from resolve - if (statusSocket && statusSocket[0] && statusSocket[0].job_status) { - $scope.job_status = statusSocket[0].job_status; - } - if ($scope.job_status === "running" && !$scope.job.elapsed) { - runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateJobElapsedTimer); - } - - // Processing of job-status messages from the websocket - toDestroy.push($scope.$on(`ws-jobs`, function(e, data) { - if (parseInt(data.unified_job_id, 10) === - parseInt($scope.job.id,10)) { - // controller is defined, so set the job_status - $scope.job_status = data.status; - if(_.has(data, 'instance_group_name')){ - $scope.job.instance_group = true; - $scope.job.summary_fields.instance_group = { - "name": data.instance_group_name - }; - } - if (data.status === "running") { - if (!runTimeElapsedTimer) { - runTimeElapsedTimer = workflowResultsService.createOneSecondTimer(moment(), updateJobElapsedTimer); - } - } else if (data.status === "successful" || - data.status === "failed" || - data.status === "error" || - data.status === "canceled") { - workflowResultsService.destroyTimer(runTimeElapsedTimer); - - // When the fob is finished retrieve the job data to - // correct anything that was out of sync from the job run - jobResultsService.getJobData($scope.job.id).then(function(data){ - $scope.job = data; - $scope.jobFinished = true; - }); - } - } else if (parseInt(data.project_id, 10) === - parseInt($scope.job.project,10)) { - // this is a project status update message, so set the - // project status in the left pane - $scope.project_status = data.status; - $scope.project_update_link = `/#/scm_update/${data - .unified_job_id}`; - } else { - // controller was previously defined, but is not yet defined - // for this job. cache the socket status on root scope - $rootScope['lastSocketStatus' + data.unified_job_id] = data.status; - } - })); - - if (statusSocket && statusSocket[1]) { - statusSocket[1](); - } - - $scope.$on('$destroy', function(){ - if (statusSocket && statusSocket[1]) { - statusSocket[1](); - } - $( ".JobResultsStdOut-aLineOfStdOut" ).remove(); - cancelRequests = true; - eventQueue.initialize(); - Object.keys($scope.events) - .forEach(v => { - $scope.events[v].$destroy(); - $scope.events[v] = null; - }); - $scope.events = {}; - workflowResultsService.destroyTimer(runTimeElapsedTimer); - if (bufferInterval) { - clearInterval(bufferInterval); - } - toDestroy.forEach(closureFunc => closureFunc()); - }); -}]; diff --git a/awx/ui/client/src/job-results/job-results.partial.html b/awx/ui/client/src/job-results/job-results.partial.html deleted file mode 100644 index 9d8e1a119b..0000000000 --- a/awx/ui/client/src/job-results/job-results.partial.html +++ /dev/null @@ -1,566 +0,0 @@ -
-
-
- - -
-
- - -
-
- DETAILS -
- - -
- - - - - - - - - -
-
- - -
- - -
- -
- - {{ status_label | translate }} -
-
- - -
- -
- {{ job.job_explanation }} -
-
- {{task_detail | limitTo:explanationLimit}} - - ... - Show More - - Show Less -
-
- - -
- -
- {{ job.started | longDate }} -
-
- - -
- -
- {{ (job.finished | - longDate) || "Not Finished" }} -
-
- - -
- -
-
-
- - - - - - -
- -
- {{ type_label }} -
-
- - - - - - - - - - - - - - -
- - - -
- - -
- -
- {{ job.playbook }} -
-
- - -
- - -
- - -
- -
- - - {{ extraCredential.name }} - - {{$last ? '' : ', '}} - -
-
- - - - - - - - - - - -
- -
- {{ job.forks }} -
-
- - -
- -
- {{ job.limit }} -
-
- - -
- -
- {{ verbosity_label }} -
-
- - -
- -
- {{ job.summary_fields.instance_group.name }} - - Isolated - -
-
- - -
- -
- {{ job.job_tags }} -
-
- - -
- -
- {{ job.skip_tags }} -
-
- - -
- - -
- - -
- -
-
-
-
- {{ label }} -
-
-
-
-
- -
- -
-
- - -
-
- - -
-
- - - {{ job.name }} -
-
- -
- -
- Plays -
- - {{ playCount || 0}} - - - -
- Tasks -
- - {{ taskCount || 0}} - - - -
- Hosts -
- - {{ hostCount || 0}} - - - - - - - -
- Elapsed -
- - {{ job.elapsed * 1000 | duration: "hh:mm:ss" }} - -
- - -
- - - - - - - - - -
-
-
- - - - -
- -
-
-
diff --git a/awx/ui/client/src/job-results/job-results.route.js b/awx/ui/client/src/job-results/job-results.route.js deleted file mode 100644 index 60c06de7cd..0000000000 --- a/awx/ui/client/src/job-results/job-results.route.js +++ /dev/null @@ -1,187 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import {templateUrl} from '../shared/template-url/template-url.factory'; - -const defaultParams = { - page_size: "200", - order_by: 'start_line', - not__event__in: 'playbook_on_start,playbook_on_play_start,playbook_on_task_start,playbook_on_stats' -}; - -export default { - name: 'jobResult', - url: '/jobs/{id: int}', - searchPrefix: 'job_event', - ncyBreadcrumb: { - parent: 'jobs', - label: '{{ job.id }} - {{ job.name }}' - }, - data: { - socket: { - "groups":{ - "jobs": ["status_changed", "summary"], - "job_events": [] - } - } - }, - params: { - job_event_search: { - value: defaultParams, - dynamic: true, - squash: '' - } - }, - resolve: { - statusSocket: ['$rootScope', '$stateParams', function($rootScope, $stateParams) { - var preScope = {}; - var eventOn = $rootScope.$on(`ws-jobs`, function(e, data) { - if (parseInt(data.unified_job_id, 10) === - parseInt($stateParams.id,10)) { - preScope.job_status = data.status; - } - }); - return [preScope, eventOn]; - }], - // the GET for the particular job - jobData: ['jobResultsService', '$stateParams', function(jobResultsService, $stateParams) { - return jobResultsService.getJobData($stateParams.id); - }], - Dataset: ['QuerySet', '$stateParams', 'jobData', - function(qs, $stateParams, jobData) { - let path = jobData.related.job_events; - return qs.search(path, $stateParams[`job_event_search`]); - } - ], - // used to signify if job is completed or still running - jobFinished: ['jobData', function(jobData) { - if (jobData.finished) { - return true; - } else { - return false; - } - }], - // after the GET for the job, this helps us keep the status bar from - // flashing as rest data comes in. If the job is finished and - // there's a playbook_on_stats event, go ahead and resolve the count - // so you don't get that flashing! - count: ['jobData', 'jobResultsService', 'Rest', '$q', '$stateParams', '$state', function(jobData, jobResultsService, Rest, $q, $stateParams, $state) { - var defer = $q.defer(); - if (jobData.finished) { - // if the job is finished, grab the playbook_on_stats - // role to get the final count - Rest.setUrl(jobData.related.job_events + - "?event=playbook_on_stats"); - Rest.get() - .then(({data}) => { - if(!data.results[0]){ - defer.resolve({val: { - ok: 0, - skipped: 0, - unreachable: 0, - failures: 0, - changed: 0 - }, countFinished: false}); - } - else { - defer.resolve({ - val: jobResultsService - .getCountsFromStatsEvent(data - .results[0].event_data), - countFinished: true}); - } - }) - .catch(() => { - defer.resolve({val: { - ok: 0, - skipped: 0, - unreachable: 0, - failures: 0, - changed: 0 - }, countFinished: false}); - }); - } else { - // make sure to not include any extra - // search params for a running job (because we can't filter - // incoming job events) - if (!_.isEqual($stateParams.job_event_search, defaultParams)) { - let params = _.cloneDeep($stateParams); - params.job_event_search = defaultParams; - $state.go('.', params, { reload: true }); - } - - // job isn't finished so just send an empty count and read - // from events - defer.resolve({val: { - ok: 0, - skipped: 0, - unreachable: 0, - failures: 0, - changed: 0 - }, countFinished: false}); - } - return defer.promise; - }], - // GET for the particular jobs labels to be displayed in the - // left-hand pane - jobLabels: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { - var getNext = function(data, arr, resolve) { - Rest.setUrl(data.next); - Rest.get() - .then(({data}) => { - if (data.next) { - getNext(data, arr.concat(data.results), resolve); - } else { - resolve.resolve(arr.concat(data.results) - .map(val => val.name)); - } - }); - }; - - var seeMoreResolve = $q.defer(); - - Rest.setUrl(GetBasePath('jobs') + $stateParams.id + '/labels/'); - Rest.get() - .then(({data}) => { - if (data.next) { - getNext(data, data.results, seeMoreResolve); - } else { - seeMoreResolve.resolve(data.results - .map(val => val.name)); - } - }); - - return seeMoreResolve.promise; - }], - // OPTIONS request for the job. Used to make things like the - // verbosity data in the left-hand pane prettier than just an - // integer - jobDataOptions: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { - Rest.setUrl(GetBasePath('jobs') + $stateParams.id); - var val = $q.defer(); - Rest.options() - .then(function(data) { - val.resolve(data.data); - }, function(data) { - val.reject(data); - }); - return val.promise; - }], - jobExtraCredentials: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { - Rest.setUrl(GetBasePath('jobs') + $stateParams.id + '/extra_credentials'); - var val = $q.defer(); - Rest.get() - .then(function(res) { - val.resolve(res.data.results); - }, function(res) { - val.reject(res); - }); - return val.promise; - }] - }, - templateUrl: templateUrl('job-results/job-results'), - controller: 'jobResultsController' -}; diff --git a/awx/ui/client/src/job-results/job-results.service.js b/awx/ui/client/src/job-results/job-results.service.js deleted file mode 100644 index 6b77575fff..0000000000 --- a/awx/ui/client/src/job-results/job-results.service.js +++ /dev/null @@ -1,269 +0,0 @@ -/************************************************* -* Copyright (c) 2016 Ansible, Inc. -* -* All Rights Reserved -*************************************************/ - - -export default ['$q', 'Prompt', '$filter', 'Wait', 'Rest', '$state', 'ProcessErrors', 'GetBasePath', 'Alert', '$rootScope', 'i18n', -function ($q, Prompt, $filter, Wait, Rest, $state, ProcessErrors, GetBasePath, Alert, $rootScope, i18n) { - var val = { - // the playbook_on_stats event returns the count data in a weird format. - // format to what we need! - getCountsFromStatsEvent: function(event_data) { - var hosts = {}, - hostsArr; - - // iterate over the event_data and populate an object with hosts - // and their status data - Object.keys(event_data).forEach(key => { - // failed passes boolean not integer - if (key === "changed" || - key === "dark" || - key === "failures" || - key === "ok" || - key === "skipped") { - // array of hosts from each type ("changed", "dark", etc.) - hostsArr = Object.keys(event_data[key]); - hostsArr.forEach(host => { - if (!hosts[host]) { - // host has not been added to hosts object - // add now - hosts[host] = {}; - } - - if (!hosts[host][key]) { - // host doesn't have key - hosts[host][key] = 0; - } - hosts[host][key] += event_data[key][host]; - }); - } - }); - - var total_hosts_by_state = { - ok: 0, - skipped: 0, - unreachable: 0, - failures: 0, - changed: 0 - }; - - // each host belongs in at most *one* of these states depending on - // the state of its tasks - _.each(hosts, function(host) { - if (host.dark > 0){ - total_hosts_by_state.unreachable++; - } else if (host.failures > 0){ - total_hosts_by_state.failures++; - } else if (host.changed > 0){ - total_hosts_by_state.changed++; - } else if (host.ok > 0){ - total_hosts_by_state.ok++; - } else if (host.skipped > 0){ - total_hosts_by_state.skipped++; - } - }); - - return total_hosts_by_state; - }, - // rest call to grab previously complete job_events - getEvents: function(url) { - var val = $q.defer(); - - Rest.setUrl(url); - Rest.get() - .then(({data}) => { - val.resolve({results: data.results, - next: data.next}); - }) - .catch(({obj, status}) => { - ProcessErrors(null, obj, status, null, { - hdr: 'Error!', - msg: `Could not get job events. - Returned status: ${status}` - }); - val.reject(obj); - }); - - return val.promise; - }, - deleteJob: function(job) { - Prompt({ - hdr: i18n._("Delete Job"), - resourceName: `#${job.id} ` + $filter('sanitize')(job.name), - body: `
- ${i18n._("Are you sure you want to delete this job?")} -
`, - action: function() { - Wait('start'); - Rest.setUrl(job.url); - Rest.destroy() - .then(() => { - Wait('stop'); - $('#prompt-modal').modal('hide'); - $state.go('jobs'); - }) - .catch(({obj, status}) => { - Wait('stop'); - $('#prompt-modal').modal('hide'); - ProcessErrors(null, obj, status, null, { - hdr: 'Error!', - msg: `Could not delete job. - Returned status: ${status}` - }); - }); - }, - actionText: i18n._('DELETE') - }); - }, - cancelJob: function(job) { - var doCancel = function() { - Rest.setUrl(job.url + 'cancel'); - Rest.post({}) - .then(() => { - Wait('stop'); - $('#prompt-modal').modal('hide'); - }) - .catch(({obj, status}) => { - Wait('stop'); - $('#prompt-modal').modal('hide'); - ProcessErrors(null, obj, status, null, { - hdr: 'Error!', - msg: `Could not cancel job. - Returned status: ${status}` - }); - }); - }; - - Prompt({ - hdr: i18n._('Cancel Job'), - resourceName: `#${job.id} ` + $filter('sanitize')(job.name), - body: `
- ${i18n._("Are you sure you want to cancel this job?")} -
`, - action: function() { - Wait('start'); - Rest.setUrl(job.url + 'cancel'); - Rest.get() - .then(({data}) => { - if (data.can_cancel === true) { - doCancel(); - } else { - $('#prompt-modal').modal('hide'); - ProcessErrors(null, data, null, null, { - hdr: 'Error!', - msg: `Job has completed, - unabled to be canceled.` - }); - } - }); - }, - actionText: i18n._('PROCEED') - }); - }, - getJobData: function(id){ - var val = $q.defer(); - - Rest.setUrl(GetBasePath('jobs') + id ); - Rest.get() - .then(function(data) { - val.resolve(data.data); - }, function(data) { - val.reject(data); - - if (data.status === 404) { - Alert('Job Not Found', 'Cannot find job.', 'alert-info'); - } else if (data.status === 403) { - Alert('Insufficient Permissions', 'You do not have permission to view this job.', 'alert-info'); - } - - $state.go('jobs'); - }); - - return val.promise; - }, - // Generate a helper class for job_event statuses - // the stack for which status to display is - // unreachable > failed > changed > ok - // uses the API's runner events and convenience properties .failed .changed to determine status. - // see: job_event_callback.py for more filters to support - processEventStatus: function(event){ - if (event.event === 'runner_on_unreachable'){ - return { - class: 'HostEvent-status--unreachable', - status: 'unreachable' - }; - } - // equiv to 'runner_on_error' && 'runner on failed' - if (event.failed){ - return { - class: 'HostEvent-status--failed', - status: 'failed' - }; - } - // catch the changed case before ok, because both can be true - if (event.changed){ - return { - class: 'HostEvent-status--changed', - status: 'changed' - }; - } - if (event.event === 'runner_on_ok' || event.event === 'runner_on_async_ok'){ - return { - class: 'HostEvent-status--ok', - status: 'ok' - }; - } - if (event.event === 'runner_on_skipped'){ - return { - class: 'HostEvent-status--skipped', - status: 'skipped' - }; - } - }, - // GET events related to a job run - // e.g. - // ?event=playbook_on_stats - // ?parent=206&event__startswith=runner&page_size=200&order=host_name,counter - getRelatedJobEvents: function(id, params){ - var url = GetBasePath('jobs'); - url = url + id + '/job_events/?' + this.stringifyParams(params); - Rest.setUrl(url); - return Rest.get() - .then((response) => { - return response; - }) - .catch(({data, status}) => { - ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + '. GET returned: ' + status }); - }); - }, - stringifyParams: function(params){ - return _.reduce(params, (result, value, key) => { - return result + key + '=' + value + '&'; - }, ''); - }, - // the the API passes through Ansible's event_data response - // we need to massage away the verbose & redundant stdout/stderr properties - processJson: function(data){ - // configure fields to ignore - var ignored = [ - 'type', - 'event_data', - 'related', - 'summary_fields', - 'url', - 'ansible_facts', - ]; - // remove ignored properties - var result = _.chain(data).cloneDeep().forEach(function(value, key, collection){ - if (ignored.indexOf(key) > -1){ - delete collection[key]; - } - }).value(); - return result; - } - }; - return val; -}]; diff --git a/awx/ui/client/src/job-results/main.js b/awx/ui/client/src/job-results/main.js deleted file mode 100644 index f0aedc7c43..0000000000 --- a/awx/ui/client/src/job-results/main.js +++ /dev/null @@ -1,27 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import hostStatusBar from './host-status-bar/main'; -import jobResultsStdOut from './job-results-stdout/main'; -import hostEvent from './host-event/main'; - -import route from './job-results.route.js'; - -import jobResultsController from './job-results.controller'; - -import jobResultsService from './job-results.service'; -import eventQueueService from './event-queue.service'; -import parseStdoutService from './parse-stdout.service'; - -export default - angular.module('jobResults', [hostStatusBar.name, jobResultsStdOut.name, hostEvent.name, 'angularMoment']) - .run(['$stateExtender', function($stateExtender) { - $stateExtender.addState(route); - }]) - .controller('jobResultsController', jobResultsController) - .service('jobResultsService', jobResultsService) - .service('eventQueue', eventQueueService) - .service('parseStdoutService', parseStdoutService); diff --git a/awx/ui/client/src/job-results/parse-stdout.service.js b/awx/ui/client/src/job-results/parse-stdout.service.js deleted file mode 100644 index 66c969b9c4..0000000000 --- a/awx/ui/client/src/job-results/parse-stdout.service.js +++ /dev/null @@ -1,293 +0,0 @@ -/************************************************* -* Copyright (c) 2016 Ansible, Inc. -* -* All Rights Reserved -*************************************************/ - -export default ['$log', 'moment', 'i18n', function($log, moment, i18n){ - var val = { - // parses stdout string from api and formats various codes to the - // correct dom structure - prettify: function(line, unstyled){ - line = line - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - - // TODO: remove once Chris's fixes to the [K lines comes in - if (line.indexOf("[K") > -1) { - $log.error(line); - } - - if(!unstyled){ - // add span tags with color styling - line = line.replace(/u001b/g, ''); - - // ansi classes - /* jshint ignore:start */ - line = line.replace(/(|)\[1;im/g, ''); - line = line.replace(/(|)\[0;30m/g, ''); - line = line.replace(/(|)\[1;30m/g, ''); - line = line.replace(/(|)\[[0,1];31m/g, ''); - line = line.replace(/(|)\[0;32m(=|)/g, ''); - line = line.replace(/(|)\[0;32m1/g, ''); - line = line.replace(/(|)\[0;33m/g, ''); - line = line.replace(/(|)\[0;34m/g, ''); - line = line.replace(/(|)\[[0,1];35m/g, ''); - line = line.replace(/(|)\[0;36m/g, ''); - line = line.replace(/()\s/g, '$1'); - - //end span - line = line.replace(/(|)\[0m/g, ''); - /* jshint ignore:end */ - } else { - // For the host event modal in the standard out tab, - // the styling isn't necessary - line = line.replace(/u001b/g, ''); - - // ansi classes - /* jshint ignore:start */ - line = line.replace(/(|)\[[0,1];3[0-9]m(1|=|)/g, ''); - line = line.replace(/()\s/g, '$1'); - - //end span - line = line.replace(/(|)\[0m/g, ''); - /* jshint ignore:end */ - } - - return line; - }, - // adds anchor tags and tooltips to host status lines - getAnchorTags: function(event){ - if(event.event_name.indexOf("runner_") === -1){ - return `"`; - } - else{ - return ` JobResultsStdOut-stdoutColumn--clickable" ui-sref="jobResult.host-event.json({eventId: ${event.id}, taskUuid: '${event.event_data.task_uuid}' })" aw-tool-tip="${i18n._("Event ID")}: ${event.id}
${i18n._("Status")}: ${event.event_display}
${i18n._("Click for details")}" data-placement="top"`; - } - - }, - // this adds classes based on event data to the - // .JobResultsStdOut-aLineOfStdOut element - getLineClasses: function(event, line, lineNum) { - var string = ""; - - if (lineNum === event.end_line) { - // used to tell you where to put stuff in the pane - string += ` next_is_${event.end_line + 1}`; - } - - if (event.event_name === "playbook_on_play_start") { - // play header classes - string += " header_play"; - string += " header_play_" + event.event_data.play_uuid; - - // give the actual header class to the line with the - // actual header info (think cowsay) - if (line.indexOf("PLAY") > -1) { - string += " actual_header"; - } - } else if (event.event_name === "playbook_on_task_start") { - // task header classes - string += " header_task"; - string += " header_task_" + event.event_data.task_uuid; - - // give the actual header class to the line with the - // actual header info (think cowsay) - if (line.indexOf("TASK") > -1 || - line.indexOf("RUNNING HANDLER") > -1) { - string += " actual_header"; - } - - // task headers also get classed by their parent play - // if applicable - if (event.event_data.play_uuid) { - string += " play_" + event.event_data.play_uuid; - } - } else if (event.event_name !== "playbook_on_stats"){ - string += " not_skeleton"; - // host status or debug line - - // these get classed by their parent play if applicable - if (event.event_data.play_uuid) { - string += " play_" + event.event_data.play_uuid; - } - // as well as their parent task if applicable - if (event.event_data.task_uuid) { - string += " task_" + event.event_data.task_uuid; - } - } - - // TODO: adding this line_num_XX class is hacky because the - // line number is availabe in children of this dom element - string += " line_num_" + lineNum; - - return string; - }, - getStartTimeBadge: function(event, line){ - // This will return a div with the badge class - // for the start time to show at the right hand - // side of each stdout header line. - // returns an empty string if not a header line - var emptySpan = "", time; - if ((event.event_name === "playbook_on_play_start" || - event.event_name === "playbook_on_task_start") && - line !== "") { - time = moment(event.created).format('HH:mm:ss'); - return `
${time}
`; - } - else if(event.event_name === "playbook_on_stats" && line.indexOf("PLAY") > -1){ - time = moment(event.created).format('HH:mm:ss'); - return `
${time}
`; - } - else { - return emptySpan; - } - - }, - // used to add expand/collapse icon next to line numbers of headers - getCollapseIcon: function(event, line) { - var clickClass, - expanderizerSpecifier; - - var emptySpan = ` -`; - - if ((event.event_name === "playbook_on_play_start" || - event.event_name === "playbook_on_task_start") && - line !== "") { - if (event.event_name === "playbook_on_play_start" && - line.indexOf("PLAY") > -1) { - // play header specific attrs - expanderizerSpecifier = "play"; - clickClass = "play_" + - event.event_data.play_uuid; - } else if (line.indexOf("TASK") > -1 || - line.indexOf("RUNNING HANDLER") > -1) { - // task header specific attrs - expanderizerSpecifier = "task"; - clickClass = "task_" + - event.event_data.task_uuid; - } else { - // header lines that don't have PLAY, TASK, - // or RUNNING HANDLER in them don't get - // expand icon. - // This provides cowsay support. - return emptySpan; - } - - - var expandDom = ` - - - -`; - return expandDom; - } else { - // non-header lines don't get an expander - return emptySpan; - } - }, - distributeColors: function(lines) { - var colorCode; - return lines.map(line => { - - if (colorCode) { - line = colorCode + line; - } - - if (line.indexOf("[0m") === -1) { - if (line.indexOf("[1;31m") > -1) { - colorCode = "[1;31m"; - } else if (line.indexOf("[1;30m") > -1) { - colorCode = "[1;30m"; - } else if (line.indexOf("[0;31m") > -1) { - colorCode = "[0;31m"; - } else if (line.indexOf("[0;32m=") > -1) { - colorCode = "[0;32m="; - } else if (line.indexOf("[0;32m1") > -1) { - colorCode = "[0;32m1"; - } else if (line.indexOf("[0;32m") > -1) { - colorCode = "[0;32m"; - } else if (line.indexOf("[0;33m") > -1) { - colorCode = "[0;33m"; - } else if (line.indexOf("[0;34m") > -1) { - colorCode = "[0;34m"; - } else if (line.indexOf("[0;35m") > -1) { - colorCode = "[0;35m"; - } else if (line.indexOf("[1;35m") > -1) { - colorCode = "[1;35m"; - } else if (line.indexOf("[0;36m") > -1) { - colorCode = "[0;36m"; - } - } else { - colorCode = null; - } - - return line; - }); - }, - getLineArr: function(event) { - let lineNums = _.range(event.start_line + 1, - event.end_line + 1); - - // hack around no-carriage return issues - if (!lineNums.length) { - lineNums = [event.start_line + 1]; - } - - let lines = event.stdout - .replace("\t", " ") - .split("\r\n"); - - if (lineNums.length > lines.length) { - lineNums = lineNums.slice(0, lines.length); - } - - lines = this.distributeColors(lines); - - // hack around no-carriage return issues - if (lineNums.length === lines.length) { - return _.zip(lineNums, lines); - } - - return _.zip(lineNums, lines).slice(0, -1); - }, - actualEndLine: function(event) { - return event.start_line + this.getLineArr(event).length; - }, - // public function that provides the parsed stdout line, given a - // job_event - parseStdout: function(event){ - // this utilizes the start/end lines and stdout blob - // to create an array in the format: - // [ - // [lineNum, lineText], - // [lineNum, lineText], - // ] - var lineArr = this.getLineArr(event); - - // this takes each `[lineNum: lineText]` element and calls the - // relevant helper functions in this service to build the - // parsed line of standard out - lineArr = lineArr - .map(lineArr => { - return ` -
-
${this.getCollapseIcon(event, lineArr[1])}${lineArr[0]}
-
{ Wait('stop'); if($location.path().replace(/^\//, '').split('/')[0] !== 'jobs') { - $state.go('adHocJobStdout', {id: data.id}); + $state.go('jobz', { id: data.id, type: 'command' }); } }) .catch(({data, status}) => { diff --git a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js index 37fd3292b3..2e80cdc369 100644 --- a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js +++ b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js @@ -149,8 +149,8 @@ export default if(base !== 'portal' && Empty(data.system_job) || (base === 'home')){ // use $state.go with reload: true option to re-instantiate sockets in - var goTojobResults = function(state) { - $state.go(state, {id: job}, {reload:true}); + var goTojobResults = function(type) { + $state.go('jobz', {id: job, type}, {reload:true}); }; if($state.includes('jobs')) { @@ -159,23 +159,23 @@ export default else { if(_.has(data, 'job')) { - goTojobResults('jobResult'); + goTojobResults('playbook'); } else if(data.type && data.type === 'workflow_job') { job = data.id; - goTojobResults('workflowResults'); + goTojobResults('workflow_job'); } else if(_.has(data, 'ad_hoc_command')) { - goTojobResults('adHocJobStdout'); + goTojobResults('ad_hoc_command'); } else if(_.has(data, 'system_job')) { - goTojobResults('managementJobStdout'); + goTojobResults('system_job'); } else if(_.has(data, 'project_update')) { // If we are on the projects list or any child state of that list // then we want to stay on that page. Otherwise go to the stdout // view. if(!$state.includes('projects')) { - goTojobResults('scmUpdateStdout'); + goTojobResults('project_update'); } } else if(_.has(data, 'inventory_update')) { @@ -183,7 +183,7 @@ export default // page then we want to stay on that page. Otherwise go to the stdout // view. if(!$state.includes('inventories.edit')) { - goTojobResults('inventorySyncStdout'); + goTojobResults('playbook'); } } } diff --git a/awx/ui/client/src/management-jobs/card/card.controller.js b/awx/ui/client/src/management-jobs/card/card.controller.js index a66f5d7ec5..12dceb22b9 100644 --- a/awx/ui/client/src/management-jobs/card/card.controller.js +++ b/awx/ui/client/src/management-jobs/card/card.controller.js @@ -132,7 +132,7 @@ export default Wait('stop'); $("#prompt-for-days-facts").dialog("close"); $("#configure-dialog").dialog('close'); - $state.go('managementJobStdout', {id: data.system_job}, {reload:true}); + $state.go('jobz', { id: data.system_job, type: 'system' }, { reload: true }); }) .catch(({data, status}) => { let template_id = scope.job_template_id; @@ -222,7 +222,7 @@ export default Wait('stop'); $("#prompt-for-days").dialog("close"); // $("#configure-dialog").dialog('close'); - $state.go('managementJobStdout', {id: data.system_job}, {reload:true}); + $state.go('jobz', { id: data.system_job, type: 'system' }, { reload: true }); }) .catch(({data, status}) => { let template_id = scope.job_template_id; diff --git a/awx/ui/client/src/organizations/linkout/controllers/organizations-inventories.controller.js b/awx/ui/client/src/organizations/linkout/controllers/organizations-inventories.controller.js index 0e74eb5132..b26fffb264 100644 --- a/awx/ui/client/src/organizations/linkout/controllers/organizations-inventories.controller.js +++ b/awx/ui/client/src/organizations/linkout/controllers/organizations-inventories.controller.js @@ -234,7 +234,7 @@ export default ['$scope', '$rootScope', '$location', $scope.viewJob = function(url) { // Pull the id out of the URL var id = url.replace(/^\//, '').split('/')[3]; - $state.go('inventorySyncStdout', { id: id }); + $state.go('jobz', { id: id, type: 'inventory' }); }; diff --git a/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js b/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js index 39c322e183..853534f142 100644 --- a/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js +++ b/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js @@ -187,7 +187,7 @@ export default ['$scope', '$rootScope', '$log', '$stateParams', 'Rest', 'Alert', // Grab the id from summary_fields var id = (data.summary_fields.current_update) ? data.summary_fields.current_update.id : data.summary_fields.last_update.id; - $state.go('scmUpdateStdout', { id: id }); + $state.go('jobz', { id: id, type: 'project' }); } else { Alert('No Updates Available', 'There is no SCM update information available for this project. An update has not yet been ' + diff --git a/awx/ui/client/src/projects/list/projects-list.controller.js b/awx/ui/client/src/projects/list/projects-list.controller.js index ec21e0009c..1bcd7db2d6 100644 --- a/awx/ui/client/src/projects/list/projects-list.controller.js +++ b/awx/ui/client/src/projects/list/projects-list.controller.js @@ -146,7 +146,7 @@ export default ['$scope', '$rootScope', '$log', 'Rest', 'Alert', // Grab the id from summary_fields var id = (data.summary_fields.current_update) ? data.summary_fields.current_update.id : data.summary_fields.last_update.id; - $state.go('scmUpdateStdout', { id: id }); + $state.go('jobz', { id: id, type: 'project'}, { reload: true }); } else { Alert(i18n._('No Updates Available'), i18n._('There is no SCM update information available for this project. An update has not yet been ' + diff --git a/awx/ui/client/src/shared/smart-search/queryset.service.js b/awx/ui/client/src/shared/smart-search/queryset.service.js index 943f63818b..7d79ac1ec1 100644 --- a/awx/ui/client/src/shared/smart-search/queryset.service.js +++ b/awx/ui/client/src/shared/smart-search/queryset.service.js @@ -1,311 +1,507 @@ -export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSearchModel', 'SmartSearchService', - function($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, SmartSearchService) { - return { - // kick off building a model for a specific endpoint - // this is usually a list's basePath - // unified_jobs is the exception, where we need to fetch many subclass OPTIONS and summary_fields - initFieldset(path, name) { - let defer = $q.defer(); - defer.resolve(this.getCommonModelOptions(path, name)); - return defer.promise; - }, +function searchWithoutKey (term, singleSearchParam = null) { + if (singleSearchParam) { + return { [singleSearchParam]: `search=${encodeURIComponent(term)}` }; + } + return { search: encodeURIComponent(term) }; +} - getCommonModelOptions(path, name) { - let resolve, base, - defer = $q.defer(); +function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearchModel, SmartSearchService) { + return { + // kick off building a model for a specific endpoint + // this is usually a list's basePath + // unified_jobs is the exception, where we need to fetch many subclass OPTIONS and summary_fields + initFieldset(path, name) { + let defer = $q.defer(); + defer.resolve(this.getCommonModelOptions(path, name)); + return defer.promise; + }, + getCommonModelOptions(path, name) { + let resolve, base, + defer = $q.defer(); - this.url = path; - resolve = this.options(path) - .then((res) => { - base = res.data.actions.GET; - let relatedSearchFields = res.data.related_search_fields; - defer.resolve({ - models: { - [name]: new DjangoSearchModel(name, base, relatedSearchFields) - }, - options: res - }); + this.url = path; + resolve = this.options(path) + .then((res) => { + base = res.data.actions.GET; + let relatedSearchFields = res.data.related_search_fields; + defer.resolve({ + models: { + [name]: new DjangoSearchModel(name, base, relatedSearchFields) + }, + options: res }); - return defer.promise; - }, + }); + return defer.promise; + }, + replaceDefaultFlags (value) { + value = value.toString().replace(/__icontains_DEFAULT/g, "__icontains"); + value = value.toString().replace(/__search_DEFAULT/g, "__search"); - replaceDefaultFlags (value) { - value = value.toString().replace(/__icontains_DEFAULT/g, "__icontains"); - value = value.toString().replace(/__search_DEFAULT/g, "__search"); + return value; + }, + replaceEncodedTokens(value) { + return decodeURIComponent(value).replace(/"|'/g, ""); + }, + encodeTerms (values, key) { + key = this.replaceDefaultFlags(key); - return value; - }, + if (!Array.isArray(values)) { + values = [values]; + } - replaceEncodedTokens(value) { - return decodeURIComponent(value).replace(/"|'/g, ""); - }, + return values + .map(value => { + value = this.replaceDefaultFlags(value); + value = this.replaceEncodedTokens(value); + return [key, value]; + }); - encodeTerms (values, key) { - key = this.replaceDefaultFlags(key); + }, + // encodes ui-router params from {operand__key__comparator: value} pairs to API-consumable URL + encodeQueryset(params) { + if (typeof params !== 'object') { + return ''; + } - if (!Array.isArray(values)) { - values = this.replaceEncodedTokens(values); - - return `${key}=${values}`; + return _.reduce(params, (result, value, key) => { + if (result !== '?') { + result += '&'; } - return values - .map(value => { - value = this.replaceDefaultFlags(value); - value = this.replaceEncodedTokens(value); - - return `${key}=${value}`; - }) + const encodedTermString = this.encodeTerms(value, key) + .map(([key, value]) => `${key}=${value}`) .join('&'); - }, - // encodes ui-router params from {operand__key__comparator: value} pairs to API-consumable URL - encodeQueryset(params) { - if (typeof params !== 'object') { - return ''; + + return result += encodedTermString; + }, '?'); + }, + // like encodeQueryset, but return an actual unstringified API-consumable http param object + encodeQuerysetObject(params) { + return _.reduce(params, (obj, value, key) => { + const encodedTerms = this.encodeTerms(value, key); + + for (let encodedIndex in encodedTerms) { + const [encodedKey, encodedValue] = encodedTerms[encodedIndex]; + obj[encodedKey] = obj[encodedKey] || []; + obj[encodedKey].push(encodedValue); } - return _.reduce(params, (result, value, key) => { - if (result !== '?') { - result += '&'; - } - - return result += this.encodeTerms(value, key); - }, '?'); - }, - // encodes a ui smart-search param to a django-friendly param - // operand:key:comparator:value => {operand__key__comparator: value} - encodeParam(params){ - // Assumption here is that we have a key and a value so the length - // of the paramParts array will be 2. [0] is the key and [1] the value - let paramParts = SmartSearchService.splitTermIntoParts(params.term); - let keySplit = paramParts[0].split('.'); - let exclude = false; - let lessThanGreaterThan = paramParts[1].match(/^(>|<).*$/) ? true : false; - if(keySplit[0].match(/^-/g)) { - exclude = true; - keySplit[0] = keySplit[0].replace(/^-/, ''); - } - let paramString = exclude ? "not__" : ""; - let valueString = paramParts[1]; - if(keySplit.length === 1) { - if(params.searchTerm && !lessThanGreaterThan) { - if(params.singleSearchParam) { - paramString += keySplit[0] + '__icontains'; - } - else { - paramString += keySplit[0] + '__icontains_DEFAULT'; - } - } - else if(params.relatedSearchTerm) { - if(params.singleSearchParam) { - paramString += keySplit[0]; - } - else { - paramString += keySplit[0] + '__search_DEFAULT'; - } + return obj; + }, {}); + }, + // encodes a ui smart-search param to a django-friendly param + // operand:key:comparator:value => {operand__key__comparator: value} + encodeParam({ term, relatedSearchTerm, searchTerm, singleSearchParam }){ + // Assumption here is that we have a key and a value so the length + // of the paramParts array will be 2. [0] is the key and [1] the value + let paramParts = SmartSearchService.splitTermIntoParts(term); + let keySplit = paramParts[0].split('.'); + let exclude = false; + let lessThanGreaterThan = paramParts[1].match(/^(>|<).*$/) ? true : false; + if(keySplit[0].match(/^-/g)) { + exclude = true; + keySplit[0] = keySplit[0].replace(/^-/, ''); + } + let paramString = exclude ? "not__" : ""; + let valueString = paramParts[1]; + if(keySplit.length === 1) { + if(searchTerm && !lessThanGreaterThan) { + if(singleSearchParam) { + paramString += keySplit[0] + '__icontains'; } else { + paramString += keySplit[0] + '__icontains_DEFAULT'; + } + } + else if(relatedSearchTerm) { + if(singleSearchParam) { paramString += keySplit[0]; } - } - else { - paramString += keySplit.join('__'); - } - - if(lessThanGreaterThan) { - if(paramParts[1].match(/^>=.*$/)) { - paramString += '__gte'; - valueString = valueString.replace(/^(>=)/,""); - } - else if(paramParts[1].match(/^<=.*$/)) { - paramString += '__lte'; - valueString = valueString.replace(/^(<=)/,""); - } - else if(paramParts[1].match(/^<.*$/)) { - paramString += '__lt'; - valueString = valueString.replace(/^(<)/,""); - } - else if(paramParts[1].match(/^>.*$/)) { - paramString += '__gt'; - valueString = valueString.replace(/^(>)/,""); - } - } - - if(params.singleSearchParam) { - return {[params.singleSearchParam]: paramString + "=" + valueString}; - } - else { - return {[paramString] : encodeURIComponent(valueString)}; - } - }, - // decodes a django queryset param into a ui smart-search tag or set of tags - decodeParam(value, key){ - - let decodeParamString = function(searchString) { - if(key === 'search') { - // Don't include 'search:' in the search tag - return decodeURIComponent(`${searchString}`); - } else { - key = key.toString().replace(/__icontains_DEFAULT/g, ""); - key = key.toString().replace(/__search_DEFAULT/g, ""); - let split = key.split('__'); - let decodedParam = searchString; - let exclude = false; - if(key.startsWith('not__')) { - exclude = true; - split = split.splice(1, split.length); - } - if(key.endsWith('__gt')) { - decodedParam = '>' + decodedParam; - split = split.splice(0, split.length-1); - } - else if(key.endsWith('__lt')) { - decodedParam = '<' + decodedParam; - split = split.splice(0, split.length-1); - } - else if(key.endsWith('__gte')) { - decodedParam = '>=' + decodedParam; - split = split.splice(0, split.length-1); - } - else if(key.endsWith('__lte')) { - decodedParam = '<=' + decodedParam; - split = split.splice(0, split.length-1); - } - - let uriDecodedParam = decodeURIComponent(decodedParam); - - return exclude ? `-${split.join('.')}:${uriDecodedParam}` : `${split.join('.')}:${uriDecodedParam}`; + paramString += keySplit[0] + '__search_DEFAULT'; } - }; + } + else { + paramString += keySplit[0]; + } + } + else { + paramString += keySplit.join('__'); + } + if(lessThanGreaterThan) { + if(paramParts[1].match(/^>=.*$/)) { + paramString += '__gte'; + valueString = valueString.replace(/^(>=)/,""); + } + else if(paramParts[1].match(/^<=.*$/)) { + paramString += '__lte'; + valueString = valueString.replace(/^(<=)/,""); + } + else if(paramParts[1].match(/^<.*$/)) { + paramString += '__lt'; + valueString = valueString.replace(/^(<)/,""); + } + else if(paramParts[1].match(/^>.*$/)) { + paramString += '__gt'; + valueString = valueString.replace(/^(>)/,""); + } + } + + if(singleSearchParam) { + return {[singleSearchParam]: paramString + "=" + valueString}; + } + else { + return {[paramString] : encodeURIComponent(valueString)}; + } + }, + // decodes a django queryset param into a ui smart-search tag or set of tags + decodeParam(value, key){ + + let decodeParamString = function(searchString) { + if(key === 'search') { + // Don't include 'search:' in the search tag + return decodeURIComponent(`${searchString}`); + } + else { + key = key.toString().replace(/__icontains_DEFAULT/g, ""); + key = key.toString().replace(/__search_DEFAULT/g, ""); + let split = key.split('__'); + let decodedParam = searchString; + let exclude = false; + if(key.startsWith('not__')) { + exclude = true; + split = split.splice(1, split.length); + } + if(key.endsWith('__gt')) { + decodedParam = '>' + decodedParam; + split = split.splice(0, split.length-1); + } + else if(key.endsWith('__lt')) { + decodedParam = '<' + decodedParam; + split = split.splice(0, split.length-1); + } + else if(key.endsWith('__gte')) { + decodedParam = '>=' + decodedParam; + split = split.splice(0, split.length-1); + } + else if(key.endsWith('__lte')) { + decodedParam = '<=' + decodedParam; + split = split.splice(0, split.length-1); + } + + let uriDecodedParam = decodeURIComponent(decodedParam); + + return exclude ? `-${split.join('.')}:${uriDecodedParam}` : `${split.join('.')}:${uriDecodedParam}`; + } + }; + + if (Array.isArray(value)){ + value = _.uniq(_.flattenDeep(value)); + return _.map(value, (item) => { + return decodeParamString(item); + }); + } + else { + return decodeParamString(value); + } + }, + // encodes a django queryset for ui-router's URLMatcherFactory + // {operand__key__comparator: value, } => 'operand:key:comparator:value;...' + // value.isArray expands to: + // {operand__key__comparator: [value1, value2], } => 'operand:key:comparator:value1;operand:key:comparator:value1...' + encodeArr(params) { + let url; + url = _.reduce(params, (result, value, key) => { + return result.concat(encodeUrlString(value, key)); + }, []); + + return url.join(';'); + + // {key:'value'} => 'key:value' + // {key: [value1, value2, ...]} => ['key:value1', 'key:value2'] + function encodeUrlString(value, key){ if (Array.isArray(value)){ value = _.uniq(_.flattenDeep(value)); return _.map(value, (item) => { - return decodeParamString(item); + return `${key}:${item}`; }); } else { - return decodeParamString(value); - } - }, - - // encodes a django queryset for ui-router's URLMatcherFactory - // {operand__key__comparator: value, } => 'operand:key:comparator:value;...' - // value.isArray expands to: - // {operand__key__comparator: [value1, value2], } => 'operand:key:comparator:value1;operand:key:comparator:value1...' - encodeArr(params) { - let url; - url = _.reduce(params, (result, value, key) => { - return result.concat(encodeUrlString(value, key)); - }, []); - - return url.join(';'); - - // {key:'value'} => 'key:value' - // {key: [value1, value2, ...]} => ['key:value1', 'key:value2'] - function encodeUrlString(value, key){ - if (Array.isArray(value)){ - value = _.uniq(_.flattenDeep(value)); - return _.map(value, (item) => { - return `${key}:${item}`; - }); - } - else { - return `${key}:${value}`; - } - } - }, - - // decodes a django queryset for ui-router's URLMatcherFactory - // 'operand:key:comparator:value,...' => {operand__key__comparator: value, } - decodeArr(arr) { - let params = {}; - _.forEach(arr.split(';'), (item) => { - let key = item.split(':')[0], - value = item.split(':')[1]; - if(!params[key]){ - params[key] = value; - } - else if (Array.isArray(params[key])){ - params[key] = _.uniq(_.flattenDeep(params[key])); - params[key].push(value); - } - else { - params[key] = [params[key], value]; - } - }); - return params; - }, - // REST utilities - options(endpoint) { - Rest.setUrl(endpoint); - return Rest.options(endpoint); - }, - search(endpoint, params) { - Wait('start'); - this.url = `${endpoint}${this.encodeQueryset(params)}`; - Rest.setUrl(this.url); - - return Rest.get() - .then(function(response) { - Wait('stop'); - - if (response - .headers('X-UI-Max-Events') !== null) { - response.data.maxEvents = response. - headers('X-UI-Max-Events'); - } - - return response; - }) - .catch(function(response) { - Wait('stop'); - - this.error(response.data, response.status); - - throw response; - }.bind(this)); - }, - error(data, status) { - if(data && data.detail){ - let error = typeof data.detail === "string" ? data.detail : JSON.parse(data.detail); - - if(_.isArray(error)){ - data.detail = error[0]; - } - } - ProcessErrors($rootScope, data, status, null, { - hdr: 'Error!', - msg: `Invalid search term entered. GET returned: ${status}` - }); - }, - // Removes state definition defaults and pagination terms - stripDefaultParams(params, defaults) { - if(defaults) { - let stripped =_.pick(params, (value, key) => { - // setting the default value of a term to null in a state definition is a very explicit way to ensure it will NEVER generate a search tag, even with a non-default value - return defaults[key] !== value && key !== 'order_by' && key !== 'page' && key !== 'page_size' && defaults[key] !== null; - }); - let strippedCopy = _.cloneDeep(stripped); - if(_.keys(_.pick(defaults, _.keys(strippedCopy))).length > 0){ - for (var key in strippedCopy) { - if (strippedCopy.hasOwnProperty(key)) { - let value = strippedCopy[key]; - if(_.isArray(value)){ - let index = _.indexOf(value, defaults[key]); - value = value.splice(index, 1)[0]; - } - } - } - stripped = strippedCopy; - } - return _(strippedCopy).map(this.decodeParam).flatten().value(); - } - else { - return _(params).map(this.decodeParam).flatten().value(); + return `${key}:${value}`; } } - }; - } + }, + // decodes a django queryset for ui-router's URLMatcherFactory + // 'operand:key:comparator:value,...' => {operand__key__comparator: value, } + decodeArr(arr) { + let params = {}; + + if (!arr) { + return params; + } + + _.forEach(arr.split(';'), (item) => { + let key = item.split(':')[0], + value = item.split(':')[1]; + if(!params[key]){ + params[key] = value; + } + else if (Array.isArray(params[key])){ + params[key] = _.uniq(_.flattenDeep(params[key])); + params[key].push(value); + } + else { + params[key] = [params[key], value]; + } + }); + return params; + }, + // REST utilities + options(endpoint) { + Rest.setUrl(endpoint); + return Rest.options(endpoint); + }, + search(endpoint, params) { + Wait('start'); + this.url = `${endpoint}${this.encodeQueryset(params)}`; + Rest.setUrl(this.url); + + return Rest.get() + .then(function(response) { + Wait('stop'); + + if (response + .headers('X-UI-Max-Events') !== null) { + response.data.maxEvents = response. + headers('X-UI-Max-Events'); + } + + return response; + }) + .catch(function(response) { + Wait('stop'); + + this.error(response.data, response.status); + + throw response; + }.bind(this)); + }, + error(data, status) { + if(data && data.detail){ + let error = typeof data.detail === "string" ? data.detail : JSON.parse(data.detail); + + if(_.isArray(error)){ + data.detail = error[0]; + } + } + ProcessErrors($rootScope, data, status, null, { + hdr: 'Error!', + msg: `Invalid search term entered. GET returned: ${status}` + }); + }, + // Removes state definition defaults and pagination terms + stripDefaultParams(params, defaultParams) { + if (!params) { + return []; + } + if(defaultParams) { + let stripped =_.pick(params, (value, key) => { + // setting the default value of a term to null in a state definition is a very explicit way to ensure it will NEVER generate a search tag, even with a non-default value + return defaultParams[key] !== value && key !== 'order_by' && key !== 'page' && key !== 'page_size' && defaultParams[key] !== null; + }); + let strippedCopy = _.cloneDeep(stripped); + if(_.keys(_.pick(defaultParams, _.keys(strippedCopy))).length > 0){ + for (var key in strippedCopy) { + if (strippedCopy.hasOwnProperty(key)) { + let value = strippedCopy[key]; + if(_.isArray(value)){ + let index = _.indexOf(value, defaultParams[key]); + value = value.splice(index, 1)[0]; + } + } + } + stripped = strippedCopy; + } + return _(strippedCopy).map(this.decodeParam).flatten().value(); + } + else { + return _(params).map(this.decodeParam).flatten().value(); + } + }, + mergeQueryset (queryset, additional, singleSearchParam) { + const space = '%20and%20'; + + const merged = _.merge({}, queryset, additional, (objectValue, sourceValue, key, object) => { + if (!(object[key] && object[key] !== sourceValue)) { + // // https://lodash.com/docs/3.10.1#each + // If this returns undefined merging is handled by default _.merge algorithm + return undefined; + } + + if (_.isArray(object[key])) { + object[key].push(sourceValue); + return object[key]; + } + + if (singleSearchParam) { + if (!object[key]) { + return sourceValue; + } + + const singleSearchParamKeys = object[key].split(space); + + if (_.includes(singleSearchParamKeys, sourceValue)) { + return object[key]; + } + + return `${object[key]}${space}${sourceValue}`; + } + + // Start the array of keys + return [object[key], sourceValue]; + }); + + return merged; + }, + getSearchInputQueryset (searchInput, isRelatedField = null, isAnsibleFactField = null, singleSearchParam = null) { + // XXX Should find a better approach than passing in the two 'is...Field' callbacks XXX + const space = '%20and%20'; + let params = {}; + + // Remove leading/trailing whitespace if there is any + const terms = (searchInput) ? searchInput.trim() : ''; + + if (!(terms && terms !== '')) { + return; + } + + let splitTerms; + + if (singleSearchParam === 'host_filter') { + splitTerms = SmartSearchService.splitFilterIntoTerms(terms); + } else { + splitTerms = SmartSearchService.splitSearchIntoTerms(terms); + } + + const combineSameSearches = (a, b) => { + if (!a) { + return undefined; + } + + if (_.isArray(a)) { + return a.concat(b); + } + + if (singleSearchParam) { + return `${a}${space}${b}`; + } + + return [a, b]; + }; + + _.each(splitTerms, term => { + const termParts = SmartSearchService.splitTermIntoParts(term); + let termParams; + + if (termParts.length === 1) { + termParams = searchWithoutKey(term, singleSearchParam); + } else if (isAnsibleFactField && isAnsibleFactField(termParts)) { + termParams = this.encodeParam({ term, singleSearchParam }); + } else if (isRelatedField && isRelatedField(termParts)) { + termParams = this.encodeParam({ term, singleSearchParam, related: true }); + } else { + termParams = this.encodeParam({ term, singleSearchParam }); + } + + params = _.merge(params, termParams, combineSameSearches); + }); + + return params; + }, + removeTermsFromQueryset(queryset, term, isRelatedField = null, singleSearchParam = null) { + const modifiedQueryset = _.cloneDeep(queryset); + + const removeSingleTermFromQueryset = (value, key) => { + const space = '%20and%20'; + + if (Array.isArray(modifiedQueryset[key])) { + modifiedQueryset[key] = modifiedQueryset[key].filter(item => item !== value); + if (modifiedQueryset[key].length < 1) { + delete modifiedQueryset[key]; + } + } else if (singleSearchParam && _.get(modifiedQueryset, singleSearchParam, []).includes(space)) { + const searchParamParts = modifiedQueryset[singleSearchParam].split(space); + // The value side of each paramPart might have been encoded in + // SmartSearch.splitFilterIntoTerms + _.each(searchParamParts, (paramPart, paramPartIndex) => { + searchParamParts[paramPartIndex] = decodeURIComponent(paramPart); + }); + + const paramPartIndex = searchParamParts.indexOf(value); + + if (paramPartIndex !== -1) { + searchParamParts.splice(paramPartIndex, 1); + } + + modifiedQueryset[singleSearchParam] = searchParamParts.join(space); + + } else { + delete modifiedQueryset[key]; + } + }; + + const termParts = SmartSearchService.splitTermIntoParts(term); + + let removed; + + if (termParts.length === 1) { + removed = searchWithoutKey(term, singleSearchParam); + } else if (isRelatedField && isRelatedField(termParts)) { + removed = this.encodeParam({ term, singleSearchParam, related: true }); + } else { + removed = this.encodeParam({ term, singleSearchParam }); + } + + if (!removed) { + removed = searchWithoutKey(termParts[termParts.length - 1], singleSearchParam); + } + + _.each(removed, removeSingleTermFromQueryset); + + return modifiedQueryset; + }, + createSearchTagsFromQueryset(queryset, defaultParams = null, singleSearchParam = null) { + const space = '%20and%20'; + const modifiedQueryset = angular.copy(queryset); + + let searchTags = []; + + if (singleSearchParam && modifiedQueryset[singleSearchParam]) { + const searchParam = modifiedQueryset[singleSearchParam].split(space); + delete modifiedQueryset[singleSearchParam]; + + $.each(searchParam, (index, param) => { + const paramParts = decodeURIComponent(param).split(/=(.+)/); + const reconstructedSearchString = this.decodeParam(paramParts[1], paramParts[0]); + + searchTags.push(reconstructedSearchString); + }); + } + + return searchTags.concat(this.stripDefaultParams(modifiedQueryset, defaultParams)); + } + }; +} + +QuerysetService.$inject = [ + '$q', + 'Rest', + 'ProcessErrors', + '$rootScope', + 'Wait', + 'DjangoSearchModel', + 'SmartSearchService', ]; + +export default QuerysetService; diff --git a/awx/ui/client/src/shared/smart-search/smart-search.controller.js b/awx/ui/client/src/shared/smart-search/smart-search.controller.js index e7dd435307..7c1e7eaca8 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.controller.js +++ b/awx/ui/client/src/shared/smart-search/smart-search.controller.js @@ -1,422 +1,254 @@ -export default ['$stateParams', '$scope', '$state', 'GetBasePath', 'QuerySet', 'SmartSearchService', 'i18n', 'ConfigService', '$transitions', - function($stateParams, $scope, $state, GetBasePath, qs, SmartSearchService, i18n, configService, $transitions) { +function SmartSearchController ( + $scope, + $state, + $stateParams, + $transitions, + configService, + GetBasePath, + i18n, + qs +) { + const searchKey = `${$scope.iterator}_search`; + const optionsKey = `${$scope.list.iterator}_options`; - let path, - defaults, - queryset, - transitionSuccessListener; + let path; + let defaults; + let queryset; + let transitionSuccessListener; - configService.getConfig() - .then(config => init(config)); + configService.getConfig() + .then(config => init(config)); - function init(config) { - let version; + function init (config) { + let version; - try { - version = config.version.split('-')[0]; - } catch (err) { - version = 'latest'; - } + try { + [version] = config.version.split('-'); + } catch (err) { + version = 'latest'; + } - $scope.documentationLink = `http://docs.ansible.com/ansible-tower/${version}/html/userguide/search_sort.html`; + $scope.documentationLink = `http://docs.ansible.com/ansible-tower/${version}/html/userguide/search_sort.html`; + $scope.searchPlaceholder = i18n._('Search'); - if($scope.defaultParams) { - defaults = $scope.defaultParams; - } - else { - // steps through the current tree of $state configurations, grabs default search params - defaults = _.find($state.$current.path, (step) => { - if(step && step.params && step.params.hasOwnProperty(`${$scope.iterator}_search`)){ - return step.params.hasOwnProperty(`${$scope.iterator}_search`); - } - }).params[`${$scope.iterator}_search`].config.value; - } + if ($scope.defaultParams) { + defaults = $scope.defaultParams; + } else { + // steps through the current tree of $state configurations, grabs default search params + const stateConfig = _.find($state.$current.path, step => _.has(step, `params.${searchKey}`)); + defaults = stateConfig.params[searchKey].config.value; + } - if($scope.querySet) { - queryset = _.cloneDeep($scope.querySet); - } - else { - queryset = $state.params[`${$scope.iterator}_search`]; - } + if ($scope.querySet) { + queryset = _.cloneDeep($scope.querySet); + } else { + queryset = $state.params[searchKey]; + } - path = GetBasePath($scope.basePath) || $scope.basePath; - generateSearchTags(); - qs.initFieldset(path, $scope.djangoModel).then((data) => { + path = GetBasePath($scope.basePath) || $scope.basePath; + generateSearchTags(); + + qs.initFieldset(path, $scope.djangoModel) + .then((data) => { $scope.models = data.models; $scope.options = data.options.data; if ($scope.list) { - $scope.$emit(`${$scope.list.iterator}_options`, data.options); - } - }); - $scope.searchPlaceholder = $scope.disableSearch ? i18n._('Cannot search running job') : i18n._('Search'); - - function compareParams(a, b) { - for (let key in a) { - if (!(key in b) || a[key].toString() !== b[key].toString()) { - return false; - } - } - for (let key in b) { - if (!(key in a)) { - return false; - } - } - return true; - } - - if(transitionSuccessListener) { - transitionSuccessListener(); - } - - transitionSuccessListener = $transitions.onSuccess({}, function(trans) { - // State has changed - check to see if this is a param change - if(trans.from().name === trans.to().name) { - if(!compareParams(trans.params('from')[`${$scope.iterator}_search`], trans.params('to')[`${$scope.iterator}_search`])) { - // Params are not the same - we need to update the search. This should only happen when the user - // hits the forward/back navigation buttons in their browser. - queryset = trans.params('to')[`${$scope.iterator}_search`]; - qs.search(path, queryset).then((res) => { - $scope.dataset = res.data; - $scope.collection = res.data.results; - $scope.$emit('updateDataset', res.data); - }); - - $scope.searchTerm = null; - generateSearchTags(); - } + $scope.$emit(optionsKey, data.options); } }); - $scope.$on('$destroy', transitionSuccessListener); - - $scope.$watch('disableSearch', function(disableSearch){ - if(disableSearch) { - $scope.searchPlaceholder = i18n._('Cannot search running job'); + function compareParams (a, b) { + for (let key in a) { + if (!(key in b) || a[key].toString() !== b[key].toString()) { + return false; } - else { - $scope.searchPlaceholder = i18n._('Search'); + } + for (let key in b) { + if (!(key in a)) { + return false; } - }); + } + return true; } - function generateSearchTags() { - $scope.searchTags = []; - - let querysetCopy = angular.copy(queryset); - - if($scope.singleSearchParam && querysetCopy[$scope.singleSearchParam]) { - let searchParam = querysetCopy[$scope.singleSearchParam].split('%20and%20'); - delete querysetCopy[$scope.singleSearchParam]; - - $.each(searchParam, function(index, param) { - let paramParts = decodeURIComponent(param).split(/=(.+)/); - let reconstructedSearchString = qs.decodeParam(paramParts[1], paramParts[0]); - $scope.searchTags.push(reconstructedSearchString); - }); - } - - $scope.searchTags = $scope.searchTags.concat(qs.stripDefaultParams(querysetCopy, defaults)); + if (transitionSuccessListener) { + transitionSuccessListener(); } - function revertSearch(queryToBeRestored) { - queryset = queryToBeRestored; - // https://ui-router.github.io/docs/latest/interfaces/params.paramdeclaration.html#dynamic - // This transition will not reload controllers/resolves/views - // but will register new $stateParams[$scope.iterator + '_search'] terms - if(!$scope.querySet) { - $state.go('.', { - [$scope.iterator + '_search']: queryset }); - } - qs.search(path, queryset).then((res) => { - if($scope.querySet) { - $scope.querySet = queryset; - } - $scope.dataset = res.data; - $scope.collection = res.data.results; - }); - - $scope.searchTerm = null; - - generateSearchTags(); - } - - function searchWithoutKey(term) { - if($scope.singleSearchParam) { - return { - [$scope.singleSearchParam]: "search=" + encodeURIComponent(term) - }; - } - return { - search: encodeURIComponent(term) - }; - } - - $scope.toggleKeyPane = function() { - $scope.showKeyPane = !$scope.showKeyPane; - }; - - // add a search tag, merge new queryset, $state.go() - $scope.addTerm = function(terms) { - let params = {}, - origQueryset = _.clone(queryset); - - // Remove leading/trailing whitespace if there is any - terms = (terms) ? terms.trim() : ""; - - if(terms && terms !== '') { - let splitTerms; - - if ($scope.singleSearchParam === 'host_filter') { - splitTerms = SmartSearchService.splitFilterIntoTerms(terms); - } else { - splitTerms = SmartSearchService.splitSearchIntoTerms(terms); - } - - _.forEach(splitTerms, (term) => { - let termParts = SmartSearchService.splitTermIntoParts(term); - - function combineSameSearches(a,b){ - if (_.isArray(a)) { - return a.concat(b); - } - else { - if(a) { - if($scope.singleSearchParam) { - return a + "%20and%20" + b; - } - else { - return [a,b]; - } - } - } - } - - if($scope.singleSearchParam) { - if (termParts.length === 1) { - params = _.merge(params, searchWithoutKey(term), combineSameSearches); - } - else { - let root = termParts[0].split(".")[0].replace(/^-/, ''); - if(_.has($scope.models[$scope.list.name].base, root) || root === "ansible_facts") { - if(_.has($scope.models[$scope.list.name].base[root], "type") && $scope.models[$scope.list.name].base[root].type === 'field'){ - // Intent is to land here for searching on the base model. - params = _.merge(params, qs.encodeParam({term: term, relatedSearchTerm: true, singleSearchParam: $scope.singleSearchParam ? $scope.singleSearchParam : false}), combineSameSearches); - } - else { - // Intent is to land here when performing ansible_facts searches - params = _.merge(params, qs.encodeParam({term: term, searchTerm: true, singleSearchParam: $scope.singleSearchParam ? $scope.singleSearchParam : false}), combineSameSearches); - } - } - else if(_.contains($scope.models[$scope.list.name].related, root)) { - // Intent is to land here for related searches - params = _.merge(params, qs.encodeParam({term: term, relatedSearchTerm: true, singleSearchParam: $scope.singleSearchParam ? $scope.singleSearchParam : false}), combineSameSearches); - } - // Its not a search term or a related search term - treat it as a string - else { - params = _.merge(params, searchWithoutKey(term), combineSameSearches); - } - } - } - - else { - // if only a value is provided, search using default keys - if (termParts.length === 1) { - params = _.merge(params, searchWithoutKey(term), combineSameSearches); - } else { - // Figure out if this is a search term - let root = termParts[0].split(".")[0].replace(/^-/, ''); - if(_.has($scope.models[$scope.list.name].base, root)) { - if($scope.models[$scope.list.name].base[root].type && $scope.models[$scope.list.name].base[root].type === 'field') { - params = _.merge(params, qs.encodeParam({term: term, relatedSearchTerm: true}), combineSameSearches); - } - else { - params = _.merge(params, qs.encodeParam({term: term, searchTerm: true}), combineSameSearches); - } - } - // The related fields need to also be checked for related searches. - // The related fields for the search are retrieved from the API - // options endpoint, and are stored in the $scope.model. FYI, the - // Django search model is what sets the related fields on the model. - else if(_.contains($scope.models[$scope.list.name].related, root)) { - params = _.merge(params, qs.encodeParam({term: term, relatedSearchTerm: true}), combineSameSearches); - } - // Its not a search term or a related search term - treat it as a string - else { - params = _.merge(params, searchWithoutKey(term), combineSameSearches); - } - - } - } - }); - - queryset = _.merge({}, queryset, params, (objectValue, sourceValue, key, object) => { - if (object[key] && object[key] !== sourceValue){ - if(_.isArray(object[key])) { - // Add the new value to the array and return - object[key].push(sourceValue); - return object[key]; - } - else { - if($scope.singleSearchParam) { - if(!object[key]) { - return sourceValue; - } - else { - let singleSearchParamKeys = object[key].split("%20and%20"); - - if(_.includes(singleSearchParamKeys, sourceValue)) { - return object[key]; - } - else { - return object[key] + "%20and%20" + sourceValue; - } - } - } - // Start the array of keys - return [object[key], sourceValue]; - } - } - else { - // // https://lodash.com/docs/3.10.1#merge - // If customizer fn returns undefined merging is handled by default _.merge algorithm - return undefined; - } - }); - - // Go back to the first page after a new search - delete queryset.page; - - // https://ui-router.github.io/docs/latest/interfaces/params.paramdeclaration.html#dynamic - // This transition will not reload controllers/resolves/views - // but will register new $stateParams[$scope.iterator + '_search'] terms - if(!$scope.querySet) { - $state.go('.', {[$scope.iterator + '_search']:queryset }).then(function(){ - // ISSUE: same as above in $scope.remove. For some reason deleting the page - // from the queryset works for all lists except lists in modals. - delete $stateParams[$scope.iterator + '_search'].page; - }); - } - qs.search(path, queryset).then((res) => { - if($scope.querySet) { - $scope.querySet = queryset; - } - $scope.dataset = res.data; - $scope.collection = res.data.results; - }) - .catch(function() { - revertSearch(origQueryset); - }); - - $scope.searchTerm = null; - - generateSearchTags(); - } - }; - - // remove tag, merge new queryset, $state.go - $scope.removeTerm = function(index) { - let tagToRemove = $scope.searchTags.splice(index, 1)[0], - termParts = SmartSearchService.splitTermIntoParts(tagToRemove), - removed; - - let removeFromQuerySet = function(set) { - _.each(removed, (value, key) => { - if (Array.isArray(set[key])){ - _.remove(set[key], (item) => item === value); - // If the array is now empty, remove that key - if (set[key].length === 0) { - delete set[key]; - } - } else { - if ($scope.singleSearchParam && set[$scope.singleSearchParam] && set[$scope.singleSearchParam].includes("%20and%20")) { - let searchParamParts = set[$scope.singleSearchParam].split("%20and%20"); - // The value side of each paramPart might have been encoded in SmartSearch.splitFilterIntoTerms - _.each(searchParamParts, (paramPart, paramPartIndex) => { - searchParamParts[paramPartIndex] = decodeURIComponent(paramPart); - }); - var index = searchParamParts.indexOf(value); - if (index !== -1) { - searchParamParts.splice(index, 1); - } - set[$scope.singleSearchParam] = searchParamParts.join("%20and%20"); - } else { - delete set[key]; - } - } - }); - }; - - if (termParts.length === 1) { - removed = searchWithoutKey(tagToRemove); - } else { - let root = termParts[0].split(".")[0].replace(/^-/, ''); - let encodeParams = { - term: tagToRemove, - singleSearchParam: $scope.singleSearchParam ? $scope.singleSearchParam : false - }; - if($scope.models[$scope.list.name]) { - if($scope.singleSearchParam) { - removed = qs.encodeParam(encodeParams); - } - else if(_.has($scope.models[$scope.list.name].base, root)) { - if($scope.models[$scope.list.name].base[root].type && $scope.models[$scope.list.name].base[root].type === 'field') { - encodeParams.relatedSearchTerm = true; - } - else { - encodeParams.searchTerm = true; - } - removed = qs.encodeParam(encodeParams); - } - else if(_.contains($scope.models[$scope.list.name].related, root)) { - encodeParams.relatedSearchTerm = true; - removed = qs.encodeParam(encodeParams); - } - else { - removed = searchWithoutKey(termParts[termParts.length-1]); - } - } - else { - removed = searchWithoutKey(termParts[termParts.length-1]); - } - } - removeFromQuerySet(queryset); - if (!$scope.querySet) { - $state.go('.', { - [$scope.iterator + '_search']: queryset }).then(function(){ - // ISSUE: for some reason deleting a tag from a list in a modal does not - // remove the param from $stateParams. Here we'll manually check to make sure - // that that happened and remove it if it didn't. - - removeFromQuerySet($stateParams[`${$scope.iterator}_search`]); + transitionSuccessListener = $transitions.onSuccess({}, trans => { + // State has changed - check to see if this is a param change + if (trans.from().name === trans.to().name) { + if (!compareParams(trans.params('from')[searchKey], trans.params('to')[searchKey])) { + // Params are not the same - we need to update the search. This should only + // happen when the user hits the forward/back browser navigation buttons. + queryset = trans.params('to')[searchKey]; + qs.search(path, queryset).then((res) => { + $scope.dataset = res.data; + $scope.collection = res.data.results; + $scope.$emit('updateDataset', res.data); }); - } - qs.search(path, queryset).then((res) => { - if($scope.querySet) { - $scope.querySet = queryset; + + $scope.searchTerm = null; + generateSearchTags(); } - - $scope.dataset = res.data; - $scope.collection = res.data.results; - - generateSearchTags(); - }); - }; - - $scope.clearAllTerms = function(){ - let cleared = _.cloneDeep(defaults); - delete cleared.page; - queryset = cleared; - if(!$scope.querySet) { - $state.go('.', {[$scope.iterator + '_search']: queryset}); } - qs.search(path, queryset).then((res) => { - if($scope.querySet) { - $scope.querySet = queryset; - } - $scope.dataset = res.data; - $scope.collection = res.data.results; - }); - $scope.searchTags = qs.stripDefaultParams(queryset, defaults); - }; + }); + + $scope.$on('$destroy', transitionSuccessListener); + $scope.$watch('disableSearch', disableSearch => { + if (disableSearch) { + $scope.searchPlaceholder = i18n._('Cannot search running job'); + } else { + $scope.searchPlaceholder = i18n._('Search'); + } + }); } + + function generateSearchTags () { + const { singleSearchParam } = $scope; + $scope.searchTags = qs.createSearchTagsFromQueryset(queryset, defaults, singleSearchParam); + } + + function revertSearch (queryToBeRestored) { + queryset = queryToBeRestored; + // https://ui-router.github.io/docs/latest/interfaces/params.paramdeclaration.html#dynamic + // This transition will not reload controllers/resolves/views + // but will register new $stateParams[$scope.iterator + '_search'] terms + if (!$scope.querySet) { + $state.go('.', { [searchKey]: queryset }); + } + qs.search(path, queryset).then((res) => { + if ($scope.querySet) { + $scope.querySet = queryset; + } + $scope.dataset = res.data; + $scope.collection = res.data.results; + }); + + $scope.searchTerm = null; + + generateSearchTags(); + } + + $scope.toggleKeyPane = () => { + $scope.showKeyPane = !$scope.showKeyPane; + }; + + function isAnsibleFactField (termParts) { + const rootField = termParts[0].split('.')[0].replace(/^-/, ''); + return rootField === 'ansible_facts'; + } + + function isRelatedField (termParts) { + const rootField = termParts[0].split('.')[0].replace(/^-/, ''); + const listName = $scope.list.name; + const baseRelatedTypePath = `models.${listName}.base.${rootField}.type`; + + const isRelatedSearchTermField = (_.contains($scope.models[listName].related, rootField)); + const isBaseModelRelatedSearchTermField = (_.get($scope, baseRelatedTypePath) === 'field'); + + return (isRelatedSearchTermField || isBaseModelRelatedSearchTermField); + } + + $scope.addTerms = terms => { + const { singleSearchParam } = $scope; + const unmodifiedQueryset = _.clone(queryset); + + const searchInputQueryset = qs.getSearchInputQueryset(terms, isRelatedField, isAnsibleFactField, singleSearchParam); + const modifiedQueryset = qs.mergeQueryset(queryset, searchInputQueryset, singleSearchParam); + + // Go back to the first page after a new search + delete modifiedQueryset.page; + + // https://ui-router.github.io/docs/latest/interfaces/params.paramdeclaration.html#dynamic + // This transition will not reload controllers/resolves/views but will register new + // $stateParams[searchKey] terms. + if (!$scope.querySet) { + $state.go('.', { [searchKey]: modifiedQueryset }) + .then(() => { + // same as above in $scope.remove. For some reason deleting the page + // from the queryset works for all lists except lists in modals. + delete $stateParams[searchKey].page; + }); + } + + qs.search(path, modifiedQueryset) + .then(({ data }) => { + if ($scope.querySet) { + $scope.querySet = modifiedQueryset; + } + $scope.dataset = data; + $scope.collection = data.results; + }) + .catch(() => revertSearch(unmodifiedQueryset)); + + $scope.searchTerm = null; + + generateSearchTags(); + }; + // remove tag, merge new queryset, $state.go + $scope.removeTerm = index => { + const { singleSearchParam } = $scope; + const [term] = $scope.searchTags.splice(index, 1); + + const modifiedQueryset = qs.removeTermsFromQueryset(queryset, term, isRelatedField, singleSearchParam); + + if (!$scope.querySet) { + $state.go('.', { [searchKey]: modifiedQueryset }) + .then(() => { + // for some reason deleting a tag from a list in a modal does not + // remove the param from $stateParams. Here we'll manually check to make sure + // that that happened and remove it if it didn't. + const clearedParams = qs.removeTermsFromQueryset($stateParams[searchKey], term, isRelatedField, singleSearchParam); + $stateParams[searchKey] = clearedParams; + }); + } + + qs.search(path, queryset) + .then(({ data }) => { + if ($scope.querySet) { + $scope.querySet = queryset; + } + $scope.dataset = data; + $scope.collection = data.results; + }); + + generateSearchTags(); + }; + + $scope.clearAllTerms = () => { + const cleared = _.cloneDeep(defaults); + + delete cleared.page; + + queryset = cleared; + + if (!$scope.querySet) { + $state.go('.', { [searchKey]: queryset }); + } + + qs.search(path, queryset) + .then(({ data }) => { + if ($scope.querySet) { + $scope.querySet = queryset; + } + $scope.dataset = data; + $scope.collection = data.results; + }); + + $scope.searchTags = qs.stripDefaultParams(queryset, defaults); + }; +} + +SmartSearchController.$inject = [ + '$scope', + '$state', + '$stateParams', + '$transitions', + 'ConfigService', + 'GetBasePath', + 'i18n', + 'QuerySet', ]; + +export default SmartSearchController; diff --git a/awx/ui/client/src/shared/smart-search/smart-search.partial.html b/awx/ui/client/src/shared/smart-search/smart-search.partial.html index 1f31adcf9e..ce52fea759 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.partial.html +++ b/awx/ui/client/src/shared/smart-search/smart-search.partial.html @@ -3,11 +3,11 @@
-
+
-
+
diff --git a/awx/ui/client/src/shared/socket/socket.service.js b/awx/ui/client/src/shared/socket/socket.service.js index 7c390d0cba..0e50b0e671 100644 --- a/awx/ui/client/src/shared/socket/socket.service.js +++ b/awx/ui/client/src/shared/socket/socket.service.js @@ -90,12 +90,18 @@ export default // ex: 'ws-jobs-' str = `ws-${data.group_name}-${data.job}`; } + else if(data.group_name==="project_update_events"){ + str = `ws-${data.group_name}-${data.project_update}`; + } else if(data.group_name==="ad_hoc_command_events"){ - // The naming scheme is "ws" then a - // dash (-) and the group_name, then the job ID - // ex: 'ws-jobs-' str = `ws-${data.group_name}-${data.ad_hoc_command}`; } + else if(data.group_name==="system_job_events"){ + str = `ws-${data.group_name}-${data.system_job}`; + } + else if(data.group_name==="inventory_update_events"){ + str = `ws-${data.group_name}-${data.inventory_update}`; + } else if(data.group_name==="control"){ // As of v. 3.1.0, there is only 1 "control" // message, which is for expiring the session if the @@ -210,7 +216,7 @@ export default // socket-enabled AND socket-disabled, and whether the $state // requires a subscribe or an unsubscribe var self = this; - socketPromise.promise.then(function(){ + return socketPromise.promise.then(function(){ if(!state.data || !state.data.socket){ _.merge(state.data, {socket: {groups: {}}}); self.unsubscribe(state); diff --git a/awx/ui/client/src/smart-status/smart-status.controller.js b/awx/ui/client/src/smart-status/smart-status.controller.js index 8026a4d10c..1966e0b68c 100644 --- a/awx/ui/client/src/smart-status/smart-status.controller.js +++ b/awx/ui/client/src/smart-status/smart-status.controller.js @@ -27,7 +27,7 @@ export default ['$scope', '$filter', if (typeof $scope.templateType !== 'undefined' && $scope.templateType === 'workflow_job_template') { detailsBaseUrl = '/#/workflows/'; } else { - detailsBaseUrl = '/#/jobs/'; + detailsBaseUrl = '/#/jobz/playbook/'; } var sparkData = diff --git a/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.partial.html b/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.partial.html deleted file mode 100644 index 84afa03728..0000000000 --- a/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.partial.html +++ /dev/null @@ -1,147 +0,0 @@ -
-
-
-
-
-
-
- RESULTS -
-
-
- -
- - -
-
-
- -
-
Name
-
{{ job.module_name }}
-
- -
-
STATUS
-
- - {{ job.status }} -
-
- -
-
STARTED
-
- {{ job.started | longDate }} -
-
- -
-
FINISHED
-
- {{ job.finished | longDate }} -
-
- -
-
ELAPSED
-
- {{ job.elapsed }} seconds -
-
- -
-
Module Args
-
{{ job.module_args }}
-
- -
-
Inventory
- -
- -
-
Credential
- -
- -
-
Launched By
- -
- - -
-
Forks
-
{{ forks }}
-
- -
-
Limit
-
{{ limit }}
-
- - -
-
Verbosity
-
{{ verbosity }}
-
- -
-
- {{ 'Extra Variables' | translate }} - - -
-
- -
-
- -
-
-
-
-
-
-
STANDARD OUT
-
- - - - -
-
- -
-
-
-
-
diff --git a/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.route.js b/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.route.js deleted file mode 100644 index 899a98e9f7..0000000000 --- a/awx/ui/client/src/standard-out/adhoc/standard-out-adhoc.route.js +++ /dev/null @@ -1,36 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import { templateUrl } from '../../shared/template-url/template-url.factory'; - -export default { - name: 'adHocJobStdout', - route: '/ad_hoc_commands/:id', - templateUrl: templateUrl('standard-out/adhoc/standard-out-adhoc'), - controller: 'JobStdoutController', - ncyBreadcrumb: { - parent: "jobs", - label: "{{ job.module_name }}" - }, - data: { - jobType: 'ad_hoc_commands', - socket: { - "groups": { - "jobs": ["status_changed", "summary"], - "ad_hoc_command_events": [] - } - } - }, - resolve: { - jobData: ['Rest', 'GetBasePath', '$stateParams', function(Rest, GetBasePath, $stateParams) { - Rest.setUrl(GetBasePath('base') + 'ad_hoc_commands/' + $stateParams.id + '/'); - return Rest.get() - .then(({data}) => { - return data; - }); - }] - } -}; diff --git a/awx/ui/client/src/standard-out/inventory-sync/standard-out-inventory-sync.partial.html b/awx/ui/client/src/standard-out/inventory-sync/standard-out-inventory-sync.partial.html deleted file mode 100644 index 48f2d65b7e..0000000000 --- a/awx/ui/client/src/standard-out/inventory-sync/standard-out-inventory-sync.partial.html +++ /dev/null @@ -1,152 +0,0 @@ -
-
-
-
-
-
-
- RESULTS -
-
- - - -
-
-
- - - -
-
STATUS
-
- - {{ job.status }} -
-
- - -
-
EXPLANATION
-
- {{task_detail | limitTo:explanationLimit}} - - ... - Show More - - Show Less -
-
- -
-
LICENSE ERROR
-
- {{ job.license_error }} -
-
- -
-
STARTED
-
- {{ job.started | longDate }} -
-
- -
-
FINISHED
-
- {{ job.finished | longDate }} -
-
- -
-
ELAPSED
-
- {{ job.elapsed }} seconds -
-
- -
-
LAUNCH TYPE
-
- {{ job.launch_type }} -
-
- -
-
CREDENTIAL
- -
- -
-
SOURCE
-
- {{ source }} -
-
- -
-
REGIONS
-
- {{ source_regions }} -
-
- -
-
OVERWRITE
-
- {{ job.overwrite }} -
-
- -
-
OVERWRITE VARS
-
- {{ job.overwrite_vars }} -
-
- -
-
-
-
-
-
-
STANDARD OUT
-
- - - - -
-
- -
-
-
-
-
diff --git a/awx/ui/client/src/standard-out/inventory-sync/standard-out-inventory-sync.route.js b/awx/ui/client/src/standard-out/inventory-sync/standard-out-inventory-sync.route.js deleted file mode 100644 index bdd1a9a2b1..0000000000 --- a/awx/ui/client/src/standard-out/inventory-sync/standard-out-inventory-sync.route.js +++ /dev/null @@ -1,38 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import {templateUrl} from '../../shared/template-url/template-url.factory'; - -// TODO: figure out what this route should be - should it be inventory_sync? - -export default { - name: 'inventorySyncStdout', - route: '/inventory_sync/:id', - templateUrl: templateUrl('standard-out/inventory-sync/standard-out-inventory-sync'), - controller: 'JobStdoutController', - ncyBreadcrumb: { - parent: "jobs", - label: "{{ inventory_source_name }}" - }, - data: { - socket: { - "groups":{ - "jobs": ["status_changed", "summary"], - "inventory_update_events": [], - } - }, - jobType: 'inventory_updates' - }, - resolve: { - jobData: ['Rest', 'GetBasePath', '$stateParams', function(Rest, GetBasePath, $stateParams) { - Rest.setUrl(GetBasePath('base') + 'inventory_updates/' + $stateParams.id + '/'); - return Rest.get() - .then(({data}) => { - return data; - }); - }] - } -}; diff --git a/awx/ui/client/src/standard-out/log/main.js b/awx/ui/client/src/standard-out/log/main.js deleted file mode 100644 index bb97a737be..0000000000 --- a/awx/ui/client/src/standard-out/log/main.js +++ /dev/null @@ -1,10 +0,0 @@ -/************************************************* - * Copyright (c) 2015 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import standardOutLog from './standard-out-log.directive'; -export default - angular.module('standardOutLogDirective', []) - .directive('standardOutLog', standardOutLog); diff --git a/awx/ui/client/src/standard-out/log/standard-out-log.controller.js b/awx/ui/client/src/standard-out/log/standard-out-log.controller.js deleted file mode 100644 index 728f8faedf..0000000000 --- a/awx/ui/client/src/standard-out/log/standard-out-log.controller.js +++ /dev/null @@ -1,202 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -export default ['$log', '$rootScope', '$scope', '$state', '$stateParams', 'ProcessErrors', 'Rest', 'Wait', - function ($log, $rootScope, $scope, $state, $stateParams, ProcessErrors, Rest, Wait) { - - var api_complete = false, - current_range, - loaded_sections = [], - event_queue = 0, - auto_scroll_down=true, // programmatic scroll to bottom - live_event_processing = true, - page_size = 500, - job_id = $stateParams.id; - - $scope.should_apply_live_events = true; - - // Open up a socket for events depending on the type of job - function openSockets() { - if ($state.current.name === 'jobResult') { - $log.debug("socket watching on job_events-" + job_id); - $scope.$on(`ws-job_events-${job_id}`, function() { - $log.debug("socket fired on job_events-" + job_id); - if (api_complete) { - event_queue++; - } - }); - } - if ($state.current.name === 'adHocJobStdout') { - $log.debug("socket watching on ad_hoc_command_events-" + job_id); - $scope.$on(`ws-ad_hoc_command_events-${job_id}`, function() { - $log.debug("socket fired on ad_hoc_command_events-" + job_id); - if (api_complete) { - event_queue++; - } - }); - } - } - - openSockets(); - - // This is a trigger for loading up the standard out - if ($scope.removeLoadStdout) { - $scope.removeLoadStdout(); - } - $scope.removeLoadStdout = $scope.$on('LoadStdout', function() { - if (loaded_sections.length === 0) { - loadStdout(); - } - else if (live_event_processing) { - getNextSection(); - } - }); - - // This interval checks to see whether or not we've gotten a new - // event via sockets. If so, go out and update the standard out - // log. - $rootScope.jobStdOutInterval = setInterval( function() { - if (event_queue > 0) { - // events happened since the last check - $log.debug('checking for stdout...'); - if (loaded_sections.length === 0) { ////this if statement for refresh - $log.debug('calling LoadStdout'); - loadStdout(); - } - else if (live_event_processing) { - $log.debug('calling getNextSection'); - getNextSection(); - } - event_queue = 0; - } - }, 2000); - - // stdoutEndpoint gets passed through in the directive declaration. - // This watcher fires off loadStdout() when the endpoint becomes - // available. - $scope.$watch('stdoutEndpoint', function(newVal, oldVal) { - if(newVal && newVal !== oldVal) { - // Fire off the server call - loadStdout(); - } - }); - - // stdoutText optionall gets passed through in the directive declaration. - $scope.$watch('stdoutText', function(newVal, oldVal) { - if(newVal && newVal !== oldVal) { - $('#pre-container-content').html(newVal); - } - }); - - function loadStdout() { - if (!$scope.stdoutEndpoint) { - return; - } - - Rest.setUrl($scope.stdoutEndpoint + '?format=json&start_line=0&end_line=' + page_size); - Rest.get() - .then(({data}) => { - Wait('stop'); - if (data.content) { - api_complete = true; - $('#pre-container-content').html(data.content); - current_range = data.range; - if (data.content !== "Waiting for results...") { - loaded_sections.push({ - start: (data.range.start < 0) ? 0 : data.range.start, - end: data.range.end - }); - } - - $('#pre-container').scrollTop($('#pre-container').prop("scrollHeight")); - } - else { - api_complete = true; - } - }) - .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to retrieve stdout for job: ' + job_id + '. GET returned: ' + status }); - }); - } - - function getNextSection() { - if (!$scope.stdoutEndpoint) { - return; - } - - // get the next range of data from the API - var start = loaded_sections[loaded_sections.length - 1].end, url; - url = $scope.stdoutEndpoint + '?format=json&start_line=' + start + '&end_line=' + (start + page_size); - $('#stdoutMoreRowsBottom').fadeIn(); - Rest.setUrl(url); - Rest.get() - .then(({data}) => { - if ($('#pre-container-content').html() === "Waiting for results...") { - $('#pre-container-content').html(data.content); - } else { - $('#pre-container-content').append(data.content); - } - loaded_sections.push({ - start: (data.range.start < 0) ? 0 : data.range.start, - end: data.range.end - }); - if ($scope.should_apply_live_events) { - // if user has not disabled live event view by scrolling upward, then scroll down to the new content - current_range = data.range; - auto_scroll_down = true; // prevent auto load from happening - $('#pre-container').scrollTop($('#pre-container').prop("scrollHeight")); - } - $('#stdoutMoreRowsBottom').fadeOut(400); - }) - .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to retrieve stdout for job: ' + job_id + '. GET returned: ' + status }); - }); - } - - // lrInfiniteScroll handler - // grabs the next stdout section - $scope.stdOutGetNextSection = function(){ - if (current_range.absolute_end > current_range.end){ - var url = $scope.stdoutEndpoint + '?format=json&start_line=' + current_range.end + - '&end_line=' + (current_range.end + page_size); - Rest.setUrl(url); - Rest.get() - .then(({data}) => { - $('#pre-container-content').append(data.content); - current_range = data.range; - }) - .catch(({data, status}) => { - ProcessErrors($scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to retrieve stdout for job: ' + job_id + '. GET returned: ' + status }); - }); - } - }; - - // We watch for job status changes here. If the job completes we want to clear out the - // stdout interval and kill the live_event_processing flag. - $scope.$on(`ws-jobs`, function(e, data) { - if (parseInt(data.unified_job_id, 10) === parseInt(job_id,10)) { - if (data.status === 'failed' || data.status === 'canceled' || - data.status === 'error' || data.status === 'successful') { - if ($rootScope.jobStdOutInterval) { - window.clearInterval($rootScope.jobStdOutInterval); - } - if (live_event_processing) { - if (loaded_sections.length === 0) { - loadStdout(); - } - else { - getNextSection(); - } - } - live_event_processing = false; - } - } - }); - -}]; diff --git a/awx/ui/client/src/standard-out/log/standard-out-log.directive.js b/awx/ui/client/src/standard-out/log/standard-out-log.directive.js deleted file mode 100644 index d7d0656441..0000000000 --- a/awx/ui/client/src/standard-out/log/standard-out-log.directive.js +++ /dev/null @@ -1,47 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import standardOutLogController from './standard-out-log.controller'; -export default [ 'templateUrl', - function(templateUrl) { - return { - scope: { - stdoutEndpoint: '=', - stdoutText: '=', - jobId: '=' - }, - templateUrl: templateUrl('standard-out/log/standard-out-log'), - restrict: 'E', - controller: standardOutLogController, - link: function(scope) { - // All of our DOM related stuff will go in here - - var lastScrollTop, - direction; - - function detectDirection() { - var st = $('#pre-container').scrollTop(); - if (st > lastScrollTop) { - direction = "down"; - } else { - direction = "up"; - } - lastScrollTop = st; - return direction; - } - - $('#pre-container').bind('scroll', function() { - if (detectDirection() === "up") { - scope.should_apply_live_events = false; - } - - if ($(this).scrollTop() + $(this).height() === $(this).prop("scrollHeight")) { - scope.should_apply_live_events = true; - } - }); - } - }; -}]; diff --git a/awx/ui/client/src/standard-out/log/standard-out-log.partial.html b/awx/ui/client/src/standard-out/log/standard-out-log.partial.html deleted file mode 100644 index 45d9f70cbb..0000000000 --- a/awx/ui/client/src/standard-out/log/standard-out-log.partial.html +++ /dev/null @@ -1,8 +0,0 @@ -
-
-
-
-
- -
-
diff --git a/awx/ui/client/src/standard-out/main.js b/awx/ui/client/src/standard-out/main.js deleted file mode 100644 index 1e0f451014..0000000000 --- a/awx/ui/client/src/standard-out/main.js +++ /dev/null @@ -1,22 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import stdoutAdhocRoute from './adhoc/standard-out-adhoc.route'; -import stdoutManagementJobsRoute from './management-jobs/standard-out-management-jobs.route'; -import stdoutInventorySyncRoute from './inventory-sync/standard-out-inventory-sync.route'; -import stdoutScmUpdateRoute from './scm-update/standard-out-scm-update.route'; -import {JobStdoutController} from './standard-out.controller'; -import StandardOutHelper from './standard-out-factories/main'; -import standardOutLogDirective from './log/main'; - -export default angular.module('standardOut', [StandardOutHelper.name, standardOutLogDirective.name]) - .controller('JobStdoutController', JobStdoutController) - .run(['$stateExtender', function($stateExtender) { - $stateExtender.addState(stdoutAdhocRoute); - $stateExtender.addState(stdoutManagementJobsRoute); - $stateExtender.addState(stdoutInventorySyncRoute); - $stateExtender.addState(stdoutScmUpdateRoute); - }]); diff --git a/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.partial.html b/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.partial.html deleted file mode 100644 index 8c9740a5eb..0000000000 --- a/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.partial.html +++ /dev/null @@ -1,84 +0,0 @@ -
-
-
-
-
-
-
- RESULTS -
-
- - -
-
-
- -
-
NAME
-
{{ job.name }}
-
- -
-
STATUS
-
- - {{ job.status }} -
-
- -
-
STARTED
-
- {{ job.started | longDate }} -
-
- -
-
FINISHED
-
- {{ job.finished | longDate }} -
-
- -
-
ELAPSED
-
- {{ job.elapsed }} seconds -
-
- -
-
LAUNCH TYPE
-
- {{ job.launch_type }} -
-
- -
-
EXTRA VARIABLES
-
- -
- -
- -
-
-
-
-
-
-
STANDARD OUT
-
- -
-
- -
-
-
-
-
diff --git a/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.route.js b/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.route.js deleted file mode 100644 index e3a59e884d..0000000000 --- a/awx/ui/client/src/standard-out/management-jobs/standard-out-management-jobs.route.js +++ /dev/null @@ -1,36 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import { templateUrl } from '../../shared/template-url/template-url.factory'; - -export default { - name: 'managementJobStdout', - route: '/management_jobs/:id', - templateUrl: templateUrl('standard-out/management-jobs/standard-out-management-jobs'), - controller: 'JobStdoutController', - ncyBreadcrumb: { - parent: "jobs", - label: "{{ job.name }}" - }, - data: { - jobType: 'system_jobs', - socket: { - "groups": { - "jobs": ["status_changed", "summary"], - "system_job_events": [], - } - } - }, - resolve: { - jobData: ['Rest', 'GetBasePath', '$stateParams', function(Rest, GetBasePath, $stateParams) { - Rest.setUrl(GetBasePath('base') + 'system_jobs/' + $stateParams.id + '/'); - return Rest.get() - .then(({data}) => { - return data; - }); - }] - } -}; diff --git a/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html b/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html deleted file mode 100644 index f9828d4c02..0000000000 --- a/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.partial.html +++ /dev/null @@ -1,113 +0,0 @@ -
-
-
-
-
-
-
- RESULTS -
-
- - - -
-
-
- - - -
-
STATUS
-
- - {{ job.status }} -
-
- -
-
STARTED
-
- {{ job.started | longDate }} -
-
- -
-
FINISHED
-
- {{ job.finished | longDate }} -
-
- -
-
ELAPSED
-
- {{ job.elapsed }} seconds -
-
- -
-
LAUNCH TYPE
-
- {{ job.launch_type }} -
-
- -
-
PROJECT
- -
- -
-
CREDENTIAL
- -
- -
-
-
-
-
-
-
STANDARD OUT
-
- - - - -
-
- -
-
-
-
-
diff --git a/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.route.js b/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.route.js deleted file mode 100644 index 818509ccc7..0000000000 --- a/awx/ui/client/src/standard-out/scm-update/standard-out-scm-update.route.js +++ /dev/null @@ -1,38 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import { templateUrl } from '../../shared/template-url/template-url.factory'; - -// TODO: figure out what this route should be - should it be scm_update? - -export default { - name: 'scmUpdateStdout', - route: '/scm_update/:id', - templateUrl: templateUrl('standard-out/scm-update/standard-out-scm-update'), - controller: 'JobStdoutController', - ncyBreadcrumb: { - parent: "jobs", - label: "{{ project_name }}" - }, - data: { - jobType: 'project_updates', - socket: { - "groups": { - "jobs": ["status_changed", "summary"], - "project_update_events": [], - } - }, - }, - resolve: { - jobData: ['Rest', 'GetBasePath', '$stateParams', function(Rest, GetBasePath, $stateParams) { - Rest.setUrl(GetBasePath('base') + 'project_updates/' + $stateParams.id + '/'); - return Rest.get() - .then(({data}) => { - return data; - }); - }] - } -}; diff --git a/awx/ui/client/src/standard-out/standard-out-factories/delete-job.factory.js b/awx/ui/client/src/standard-out/standard-out-factories/delete-job.factory.js deleted file mode 100644 index 6e28362f3a..0000000000 --- a/awx/ui/client/src/standard-out/standard-out-factories/delete-job.factory.js +++ /dev/null @@ -1,145 +0,0 @@ -export default -function DeleteJob($state, Find, Rest, Wait, ProcessErrors, Prompt, Alert, - $filter, i18n) { - return function(params) { - var scope = params.scope, - id = params.id, - job = params.job, - callback = params.callback, - action, jobs, url, action_label, hdr; - - if (!job) { - if (scope.completed_jobs) { - jobs = scope.completed_jobs; - } - else if (scope.running_jobs) { - jobs = scope.running_jobs; - } - else if (scope.queued_jobs) { - jobs = scope.queued_jobs; - } - else if (scope.all_jobs) { - jobs = scope.all_jobs; - } - else if (scope.jobs) { - jobs = scope.jobs; - } - job = Find({list: jobs, key: 'id', val: id }); - } - - if (job.status === 'pending' || job.status === 'running' || job.status === 'waiting') { - url = job.related.cancel; - action_label = 'cancel'; - hdr = i18n._('Cancel'); - } else { - url = job.url; - action_label = 'delete'; - hdr = i18n._('Delete'); - } - - action = function () { - Wait('start'); - Rest.setUrl(url); - if (action_label === 'cancel') { - Rest.post() - .then(() => { - $('#prompt-modal').modal('hide'); - if (callback) { - scope.$emit(callback, action_label); - } - else { - $state.reload(); - Wait('stop'); - } - }) - .catch(({obj, status}) => { - Wait('stop'); - $('#prompt-modal').modal('hide'); - if (status === 403) { - Alert('Error', obj.detail); - } - // Ignore the error. The job most likely already finished. - // ProcessErrors(scope, data, status, null, { hdr: 'Error!', msg: 'Call to ' + url + - // ' failed. POST returned status: ' + status }); - }); - } else { - Rest.destroy() - .then(() => { - $('#prompt-modal').modal('hide'); - if (callback) { - scope.$emit(callback, action_label); - } - else { - let reloadListStateParams = null; - - if(scope.jobs.length === 1 && $state.params.job_search && !_.isEmpty($state.params.job_search.page) && $state.params.job_search.page !== '1') { - reloadListStateParams = _.cloneDeep($state.params); - reloadListStateParams.job_search.page = (parseInt(reloadListStateParams.job_search.page)-1).toString(); - } - - $state.go('.', reloadListStateParams, {reload: true}); - Wait('stop'); - } - }) - .catch(({obj, status}) => { - Wait('stop'); - $('#prompt-modal').modal('hide'); - if (status === 403) { - Alert('Error', obj.detail); - } - // Ignore the error. The job most likely already finished. - //ProcessErrors(scope, data, status, null, { hdr: 'Error!', msg: 'Call to ' + url + - // ' failed. DELETE returned status: ' + status }); - }); - } - }; - - if (scope.removeCancelNotAllowed) { - scope.removeCancelNotAllowed(); - } - scope.removeCancelNotAllowed = scope.$on('CancelNotAllowed', function() { - Wait('stop'); - Alert('Job Completed', 'The request to cancel the job could not be submitted. The job already completed.', 'alert-info'); - }); - - if (scope.removeCancelJob) { - scope.removeCancelJob(); - } - scope.removeCancelJob = scope.$on('CancelJob', function() { - var cancelBody = "
" + i18n._("Are you sure you want to submit the request to cancel this job?") + "
"; - var deleteBody = "
" + i18n._("Are you sure you want to delete this job?") + "
"; - Prompt({ - hdr: hdr, - resourceName: `#${job.id} ` + $filter('sanitize')(job.name), - body: (action_label === 'cancel' || job.status === 'new') ? cancelBody : deleteBody, - action: action, - actionText: (action_label === 'cancel' || job.status === 'new') ? i18n._("OK") : i18n._("DELETE") - }); - }); - - if (action_label === 'cancel') { - Rest.setUrl(url); - Rest.get() - .then(({data}) => { - if (data.can_cancel) { - scope.$emit('CancelJob'); - } - else { - scope.$emit('CancelNotAllowed'); - } - }) - .catch(({data, status}) => { - ProcessErrors(scope, data, status, null, { hdr: 'Error!', msg: 'Call to ' + url + - ' failed. GET returned: ' + status }); - }); - } - else { - scope.$emit('CancelJob'); - } - }; -} - -DeleteJob.$inject = -[ '$state', 'Find', 'Rest', 'Wait', - 'ProcessErrors', 'Prompt', 'Alert', '$filter', 'i18n' -]; diff --git a/awx/ui/client/src/standard-out/standard-out-factories/lookup-name.factory.js b/awx/ui/client/src/standard-out/standard-out-factories/lookup-name.factory.js deleted file mode 100644 index 43b39da58c..0000000000 --- a/awx/ui/client/src/standard-out/standard-out-factories/lookup-name.factory.js +++ /dev/null @@ -1,36 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - - export default - ['Rest', 'ProcessErrors', 'Empty', function(Rest, ProcessErrors, Empty) { - return function(params) { - var url = params.url, - scope_var = params.scope_var, - scope = params.scope, - callback = params.callback; - Rest.setUrl(url); - Rest.get() - .then(({data}) => { - if (scope_var === 'inventory_source') { - scope.inventory = data.inventory; - } - if (!Empty(data.name)) { - scope[scope_var + '_name'] = data.name; - } - - if (callback) { - scope.$emit(callback, data); - } - }) - .catch(({data, status}) => { - if (status === 403 && params.ignore_403) { - return; - } - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to retrieve ' + url + '. GET returned: ' + status }); - }); - }; - }]; diff --git a/awx/ui/client/src/standard-out/standard-out-factories/main.js b/awx/ui/client/src/standard-out/standard-out-factories/main.js deleted file mode 100644 index 935c8dca37..0000000000 --- a/awx/ui/client/src/standard-out/standard-out-factories/main.js +++ /dev/null @@ -1,13 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import lookUpName from './lookup-name.factory'; -import DeleteJob from './delete-job.factory'; - -export default - angular.module('StandardOutHelper', []) - .factory('LookUpName', lookUpName) - .factory('DeleteJob', DeleteJob); diff --git a/awx/ui/client/src/standard-out/standard-out.controller.js b/awx/ui/client/src/standard-out/standard-out.controller.js deleted file mode 100644 index 1a707aa706..0000000000 --- a/awx/ui/client/src/standard-out/standard-out.controller.js +++ /dev/null @@ -1,266 +0,0 @@ -/************************************************* - * Copyright (c) 2015 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -/** - * @ngdoc function - * @name controllers.function:JobStdout - * @description This controller's for the standard out page that can be displayed when a job runs -*/ - -export function JobStdoutController ($rootScope, $scope, $state, $stateParams, - GetBasePath, Rest, ProcessErrors, Empty, GetChoices, LookUpName, - ParseTypeChange, ParseVariableString, DeleteJob, Wait, i18n, - fieldChoices, fieldLabels, Project, Alert, InventorySource, - jobData) { - - var job_id = $stateParams.id, - jobType = $state.current.data.jobType; - - // This scope variable controls whether or not the left panel is shown and the right panel - // is expanded to take up the full screen - $scope.stdoutFullScreen = false; - $scope.toggleStdoutFullscreenTooltip = i18n._("Expand Output"); - - $scope.explanationLimit = 150; - - // Listen for job status updates that may come across via sockets. We need to check the payload - // to see whethere the updated job is the one that we're currently looking at. - $scope.$on(`ws-jobs`, function(e, data) { - if (parseInt(data.unified_job_id, 10) === parseInt(job_id,10) && $scope.job) { - $scope.job.status = data.status; - } - - if (data.status === 'failed' || data.status === 'canceled' || data.status === 'error' || data.status === 'successful') { - // Go out and refresh the job details - - Rest.setUrl(GetBasePath('base') + jobType + '/' + job_id + '/'); - Rest.get() - .then(({data}) => { - updateJobObj(data); - }); - } - }); - - $scope.previousTaskFailed = false; - - $scope.$watch('job.job_explanation', function(explanation) { - if (explanation && explanation.split(":")[0] === "Previous Task Failed") { - $scope.previousTaskFailed = true; - - var taskObj = JSON.parse(explanation.substring(explanation.split(":")[0].length + 1)); - // return a promise from the options request with the permission type choices (including adhoc) as a param - var fieldChoice = fieldChoices({ - $scope: $scope, - url: GetBasePath('unified_jobs'), - field: 'type' - }); - - // manipulate the choices from the options request to be set on - // scope and be usable by the list form - fieldChoice.then(function (choices) { - choices = - fieldLabels({ - choices: choices - }); - $scope.explanation_fail_type = choices[taskObj.job_type]; - $scope.explanation_fail_name = taskObj.job_name; - $scope.explanation_fail_id = taskObj.job_id; - $scope.task_detail = $scope.explanation_fail_type + " failed for " + $scope.explanation_fail_name + " with ID " + $scope.explanation_fail_id + "."; - }); - } else { - $scope.previousTaskFailed = false; - } - }); - - // Set the parse type so that CodeMirror knows how to display extra params YAML/JSON - $scope.parseType = 'yaml'; - - function updateJobObj(updatedJobData) { - - // Go out and get the job details based on the job type. jobType gets defined - // in the data block of the route declaration for each of the different types - // of stdout jobs. - - $scope.job = updatedJobData; - $scope.job_template_name = updatedJobData.name; - $scope.created_by = updatedJobData.summary_fields.created_by; - $scope.project_name = (updatedJobData.summary_fields.project) ? updatedJobData.summary_fields.project.name : ''; - $scope.inventory_name = (updatedJobData.summary_fields.inventory) ? updatedJobData.summary_fields.inventory.name : ''; - $scope.job_template_url = '/#/templates/' + updatedJobData.unified_job_template; - if($scope.inventory_name && updatedJobData.inventory && updatedJobData.summary_fields.inventory && updatedJobData.summary_fields.inventory.kind) { - if(updatedJobData.summary_fields.inventory.kind === '') { - $scope.inventory_url = '/#/inventories/inventory' + updatedJobData.inventory; - } - else if(updatedJobData.summary_fields.inventory.kind === 'smart') { - $scope.inventory_url = '/#/inventories/smart_inventory' + updatedJobData.inventory; - } - } - else { - $scope.inventory_url = ''; - } - $scope.project_url = ($scope.project_name && updatedJobData.project) ? '/#/projects/' + updatedJobData.project : ''; - $scope.credential_name = (updatedJobData.summary_fields.credential) ? updatedJobData.summary_fields.credential.name : ''; - $scope.credential_url = (updatedJobData.credential) ? '/#/credentials/' + updatedJobData.credential : ''; - $scope.cloud_credential_url = (updatedJobData.cloud_credential) ? '/#/credentials/' + updatedJobData.cloud_credential : ''; - if(updatedJobData.summary_fields && updatedJobData.summary_fields.source_workflow_job && - updatedJobData.summary_fields.source_workflow_job.id){ - $scope.workflow_result_link = `/#/workflows/${updatedJobData.summary_fields.source_workflow_job.id}`; - } - $scope.playbook = updatedJobData.playbook; - $scope.credential = updatedJobData.credential; - $scope.cloud_credential = updatedJobData.cloud_credential; - $scope.forks = updatedJobData.forks; - $scope.limit = updatedJobData.limit; - $scope.verbosity = updatedJobData.verbosity; - $scope.job_tags = updatedJobData.job_tags; - $scope.job.module_name = updatedJobData.module_name; - if (updatedJobData.extra_vars) { - $scope.variables = ParseVariableString(updatedJobData.extra_vars); - } - - $scope.$on('getInventorySource', function(e, d) { - $scope.inv_manage_group_link = '/#/inventories/inventory/' + d.inventory + '/inventory_sources/edit/' + d.id; - }); - - // If we have a source then we have to go get the source choices from the server - if (!Empty(updatedJobData.source)) { - if ($scope.removeChoicesReady) { - $scope.removeChoicesReady(); - } - $scope.removeChoicesReady = $scope.$on('ChoicesReady', function() { - $scope.source_choices.every(function(e) { - if (e.value === updatedJobData.source) { - $scope.source = e.label; - return false; - } - return true; - }); - }); - // GetChoices can be found in the helper: Utilities.js - // It attaches the source choices to $scope.source_choices. - // Then, when the callback is fired, $scope.source is bound - // to the corresponding label. - GetChoices({ - scope: $scope, - url: GetBasePath('inventory_sources'), - field: 'source', - variable: 'source_choices', - choice_name: 'choices', - callback: 'ChoicesReady' - }); - } - - // LookUpName can be found in the lookup-name.factory - // It attaches the name that it gets (based on the url) - // to the $scope variable defined by the attribute scope_var. - if (!Empty(updatedJobData.credential)) { - LookUpName({ - scope: $scope, - scope_var: 'credential', - url: GetBasePath('credentials') + updatedJobData.credential + '/', - ignore_403: true - }); - } - - if (!Empty(updatedJobData.inventory)) { - LookUpName({ - scope: $scope, - scope_var: 'inventory', - url: GetBasePath('inventory') + updatedJobData.inventory + '/' - }); - } - - if (!Empty(updatedJobData.project)) { - LookUpName({ - scope: $scope, - scope_var: 'project', - url: GetBasePath('projects') + updatedJobData.project + '/' - }); - } - - if (!Empty(updatedJobData.cloud_credential)) { - LookUpName({ - scope: $scope, - scope_var: 'cloud_credential', - url: GetBasePath('credentials') + updatedJobData.cloud_credential + '/', - ignore_403: true - }); - } - - if (!Empty(updatedJobData.inventory_source)) { - LookUpName({ - scope: $scope, - scope_var: 'inventory_source', - url: GetBasePath('inventory_sources') + updatedJobData.inventory_source + '/', - callback: 'getInventorySource' - }); - } - - if (updatedJobData.extra_vars) { - ParseTypeChange({ - scope: $scope, - field_id: 'pre-formatted-variables', - readOnly: true - }); - } - - // If the job isn't running we want to clear out the interval that goes out and checks for stdout updates. - // This interval is defined in the standard out log directive controller. - if (updatedJobData.status === 'successful' || updatedJobData.status === 'failed' || updatedJobData.status === 'error' || updatedJobData.status === 'canceled') { - if ($rootScope.jobStdOutInterval) { - window.clearInterval($rootScope.jobStdOutInterval); - } - } - - } - - if ($scope.removeDeleteFinished) { - $scope.removeDeleteFinished(); - } - $scope.removeDeleteFinished = $scope.$on('DeleteFinished', function(e, action) { - Wait('stop'); - if (action !== 'cancel') { - Wait('stop'); - $state.go('jobs'); - } - }); - - // TODO: this is currently not used but is necessary for cases where sockets - // are not available and a manual refresh trigger is needed. - $scope.refresh = function(){ - $scope.$emit('LoadStdout'); - }; - - // Click binding for the expand/collapse button on the standard out log - $scope.toggleStdoutFullscreen = function() { - $scope.stdoutFullScreen = !$scope.stdoutFullScreen; - - if ($scope.stdoutFullScreen === true) { - $scope.toggleStdoutFullscreenTooltip = i18n._("Collapse Output"); - } else if ($scope.stdoutFullScreen === false) { - $scope.toggleStdoutFullscreenTooltip = i18n._("Expand Output"); - } - }; - - $scope.deleteJob = function() { - DeleteJob({ - scope: $scope, - id: $scope.job.id, - job: $scope.job, - callback: 'DeleteFinished' - }); - }; - - updateJobObj(jobData); - -} - -JobStdoutController.$inject = [ '$rootScope', '$scope', '$state', - '$stateParams', 'GetBasePath', 'Rest', 'ProcessErrors', - 'Empty', 'GetChoices', 'LookUpName', 'ParseTypeChange', - 'ParseVariableString', 'DeleteJob', 'Wait', 'i18n', - 'fieldChoices', 'fieldLabels', 'ProjectModel', 'Alert', 'InventorySourceModel', - 'jobData']; diff --git a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js index 70bab1727b..7cfc754d86 100644 --- a/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js +++ b/awx/ui/client/src/templates/workflows/workflow-chart/workflow-chart.directive.js @@ -898,13 +898,13 @@ export default ['$state','moment', '$timeout', '$window', '$filter', 'Rest', 'Ge let goToJobResults = function(job_type) { if(job_type === 'job') { - $state.go('jobResult', {id: d.job.id}); + $state.go('jobz', {id: d.job.id, type: 'playbook'}); } else if(job_type === 'inventory_update') { - $state.go('inventorySyncStdout', {id: d.job.id}); + $state.go('jobz', {id: d.job.id, type: 'inventory'}); } else if(job_type === 'project_update') { - $state.go('scmUpdateStdout', {id: d.job.id}); + $state.go('jobz', {id: d.job.id, type: 'project'}); } }; diff --git a/awx/ui/client/src/standard-out/standard-out.block.less b/awx/ui/client/src/workflow-results/standard-out.block.less similarity index 97% rename from awx/ui/client/src/standard-out/standard-out.block.less rename to awx/ui/client/src/workflow-results/standard-out.block.less index fe7e0d2757..980f401c75 100644 --- a/awx/ui/client/src/standard-out/standard-out.block.less +++ b/awx/ui/client/src/workflow-results/standard-out.block.less @@ -126,9 +126,8 @@ standard-out-log { .StandardOut-panelHeaderActions { justify-content: flex-end; - display: flex; margin-left: 10px; - font-size: 20px; + font-size: 12px; } .StandardOut-actions { @@ -137,12 +136,11 @@ standard-out-log { .StandardOut-actionButton { font-size: 16px; - height: 30px; + height: 20px; min-width: 30px; color: @list-action-icon; background-color: inherit; border: none; - border-radius: 50%; } .StandardOut-actionButton:hover { @@ -171,4 +169,4 @@ standard-out-log { cursor: pointer; border-radius: 5px; font-size: 11px; -} +} \ No newline at end of file diff --git a/awx/ui/package.json b/awx/ui/package.json index 2d9f32f488..24a42aa207 100644 --- a/awx/ui/package.json +++ b/awx/ui/package.json @@ -107,12 +107,14 @@ "angular-sanitize": "~1.6.6", "angular-scheduler": "git+https://git@github.com/ansible/angular-scheduler#v0.3.2", "angular-tz-extensions": "git+https://git@github.com/ansible/angular-tz-extensions#v0.5.2", + "ansi-to-html": "^0.6.3", "babel-polyfill": "^6.26.0", "bootstrap": "^3.3.7", "bootstrap-datepicker": "^1.7.1", "codemirror": "^5.17.0", "components-font-awesome": "^4.6.1", "d3": "~3.3.13", + "html-entities": "^1.2.1", "javascript-detect-element-resize": "^0.5.3", "jquery": "~2.2.4", "jquery-ui": "^1.12.1", diff --git a/awx/ui/test/e2e/tests/smoke.js b/awx/ui/test/e2e/tests/smoke.js index 5ef4a4ebfd..931ef9f6d5 100644 --- a/awx/ui/test/e2e/tests/smoke.js +++ b/awx/ui/test/e2e/tests/smoke.js @@ -296,7 +296,7 @@ module.exports = { client.waitForElementVisible('div.spinny'); client.waitForElementNotVisible('div.spinny'); - client.waitForElementVisible('.JobResults-detailsPanel'); + client.waitForElementVisible('at-job-details'); client.waitForElementNotPresent(running, 60000); client.waitForElementVisible(success, 60000); diff --git a/awx/ui/test/e2e/tests/test-search-tag-add-remove.js b/awx/ui/test/e2e/tests/test-search-tag-add-remove.js new file mode 100644 index 0000000000..294f7aa582 --- /dev/null +++ b/awx/ui/test/e2e/tests/test-search-tag-add-remove.js @@ -0,0 +1,124 @@ +import { range } from 'lodash'; + +import { getAdminMachineCredential } from '../fixtures'; + +const spinny = 'div.spinny'; +const searchInput = 'smart-search input'; +const searchSubmit = 'smart-search i[class*="search"]'; +const searchTags = 'smart-search .SmartSearch-tagContainer'; +const searchClearAll = 'smart-search .SmartSearch-clearAll'; +const searchTagDelete = 'i[class*="fa-times"]'; + +const createTagSelector = n => `${searchTags}:nth-of-type(${n})`; +const createTagDeleteSelector = n => `${searchTags}:nth-of-type(${n}) ${searchTagDelete}`; + +const checkTags = (client, tags) => { + const strategy = 'css selector'; + + const countReached = createTagSelector(tags.length); + const countExceeded = createTagSelector(tags.length + 1); + + if (tags.length > 0) { + client.waitForElementVisible(countReached); + client.waitForElementNotPresent(countExceeded); + } + + client.elements(strategy, searchTags, tagElements => { + client.assert.equal(tagElements.value.length, tags.length); + + let n = -1; + tagElements.value.map(o => o.ELEMENT).forEach(id => { + client.elementIdText(id, ({ value }) => { + client.assert.equal(value, tags[++n]); + }); + }); + }); +}; + +module.exports = { + before: (client, done) => { + const resources = range(25).map(n => getAdminMachineCredential(`test-search-${n}`)); + + Promise.all(resources).then(done); + }, + 'add and remove search tags': client => { + const credentials = client.page.credentials(); + + client.login(); + client.waitForAngular(); + + credentials.section.navigation.waitForElementVisible('@credentials'); + credentials.section.navigation.click('@credentials'); + + client.waitForElementVisible(spinny); + client.waitForElementNotVisible(spinny); + + client.waitForElementVisible(searchInput); + client.waitForElementVisible(searchSubmit); + + client.expect.element(searchInput).enabled; + client.expect.element(searchSubmit).enabled; + + checkTags(client, []); + + client.setValue(searchInput, 'foo'); + client.click(searchSubmit); + client.waitForElementVisible(spinny); + client.waitForElementNotVisible(spinny); + + checkTags(client, ['foo']); + + client.setValue(searchInput, 'bar e2e'); + client.click(searchSubmit); + client.waitForElementVisible(spinny); + client.waitForElementNotVisible(spinny); + + checkTags(client, ['foo', 'bar', 'e2e']); + + client.click(searchClearAll); + client.waitForElementVisible(spinny); + client.waitForElementNotVisible(spinny); + + checkTags(client, []); + + client.setValue(searchInput, 'fiz name:foo'); + client.click(searchSubmit); + client.waitForElementVisible(spinny); + client.waitForElementNotVisible(spinny); + + checkTags(client, ['fiz', 'name:foo']); + + client.click(searchClearAll); + client.waitForElementVisible(spinny); + client.waitForElementNotVisible(spinny); + + checkTags(client, []); + + client.setValue(searchInput, 'hello name:world fiz'); + client.click(searchSubmit); + client.waitForElementVisible(spinny); + client.waitForElementNotVisible(spinny); + + checkTags(client, ['hello', 'fiz', 'name:world']); + + client.click(createTagDeleteSelector(2)); + client.waitForElementVisible(spinny); + client.waitForElementNotVisible(spinny); + + checkTags(client, ['hello', 'name:world']); + + client.click(createTagDeleteSelector(1)); + client.waitForElementVisible(spinny); + client.waitForElementNotVisible(spinny); + + checkTags(client, ['name:world']); + + client.click(createTagDeleteSelector(1)); + client.waitForElementVisible(spinny); + client.waitForElementNotVisible(spinny); + + checkTags(client, []); + + client.end(); + }, +}; diff --git a/awx/ui/test/spec/job-results/job-results.controller-test.js b/awx/ui/test/spec/job-results/job-results.controller-test.js deleted file mode 100644 index c05f2329f7..0000000000 --- a/awx/ui/test/spec/job-results/job-results.controller-test.js +++ /dev/null @@ -1,701 +0,0 @@ -'use strict'; -import moment from 'moment'; - -describe('Controller: jobResultsController', () => { - // Setup - let jobResultsController; - - let jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTypeChange, ParseVariableString, jobResultsService, eventQueue, $compile, eventResolve, populateResolve, $rScope, q, $log, Dataset, Rest, $state, QuerySet, i18n,fieldChoices, fieldLabels, $interval, workflowResultsService, statusSocket, jobExtraCredentials; - - statusSocket = function() { - var fn = function() {}; - return fn; - }; - jobData = { - related: {}, - summary_fields: { - inventory: { - id: null, - kind: '' - } - } - }; - jobDataOptions = { - actions: { - get: {} - } - }; - jobLabels = {}; - jobFinished = true; - count = { - val: {}, - countFinished: false - }; - eventResolve = { - results: [] - }; - populateResolve = {}; - - Dataset = { - data: {foo: "bar"} - }; - - let provideVals = () => { - angular.mock.module('jobResults', ($provide) => { - ParseTypeChange = jasmine.createSpy('ParseTypeChange'); - ParseVariableString = jasmine.createSpy('ParseVariableString'); - jobResultsService = jasmine.createSpyObj('jobResultsService', [ - 'deleteJob', - 'cancelJob', - 'relaunchJob', - 'getEvents', - 'getJobData', - ]); - eventQueue = jasmine.createSpyObj('eventQueue', [ - 'populate', - 'markProcessed', - 'initialize' - ]); - - Rest = jasmine.createSpyObj('Rest', [ - 'setUrl', - 'get' - ]); - - $state = jasmine.createSpyObj('$state', [ - 'reload' - ]); - - QuerySet = jasmine.createSpyObj('QuerySet', [ - 'encodeQueryset' - ]); - - i18n = { - _: function(txt) { - return txt; - } - }; - - $provide.service('workflowResultsService', () => { - return jasmine.createSpyObj('workflowResultsService', ['createOneSecondTimer', 'destroyTimer']); - }); - - $provide.value('statusSocket', statusSocket); - - $provide.value('jobData', jobData); - $provide.value('jobDataOptions', jobDataOptions); - $provide.value('jobLabels', jobLabels); - $provide.value('jobFinished', jobFinished); - $provide.value('count', count); - $provide.value('ParseTypeChange', ParseTypeChange); - $provide.value('ParseVariableString', ParseVariableString); - $provide.value('jobResultsService', jobResultsService); - $provide.value('eventQueue', eventQueue); - $provide.value('Dataset', Dataset); - $provide.value('Rest', Rest); - $provide.value('$state', $state); - $provide.value('QuerySet', QuerySet); - $provide.value('i18n', i18n); - $provide.value('fieldChoices', fieldChoices); - $provide.value('fieldLabels', fieldLabels); - $provide.value('jobExtraCredentials', jobExtraCredentials); - }); - }; - - let injectVals = () => { - angular.mock.inject((_jobData_, _jobDataOptions_, _jobLabels_, _jobFinished_, _count_, _ParseTypeChange_, _ParseVariableString_, _jobResultsService_, _eventQueue_, _$compile_, $rootScope, $controller, $q, $httpBackend, _$log_, _Dataset_, _Rest_, _$state_, _QuerySet_, _$interval_, _workflowResultsService_, _statusSocket_) => { - // when you call $scope.$apply() (which you need to do to - // to get inside of .then blocks to test), something is - // causing a request for all static files. - // - // this is a hack to just pass those requests through - // - // from googling this is probably due to angular-router - // weirdness - $httpBackend.when("GET", (url) => (url - .indexOf("/static/") !== -1)) - .respond(''); - - $httpBackend - .whenGET('/api') - .respond(200, ''); - - $scope = $rootScope.$new(); - $rScope = $rootScope; - q = $q; - jobData = _jobData_; - jobDataOptions = _jobDataOptions_; - jobLabels = _jobLabels_; - jobFinished = _jobFinished_; - count = _count_; - ParseTypeChange = _ParseTypeChange_; - ParseVariableString = _ParseVariableString_; - ParseVariableString.and.returnValue(jobData.extra_vars); - jobResultsService = _jobResultsService_; - eventQueue = _eventQueue_; - $log = _$log_; - Dataset = _Dataset_; - Rest = _Rest_; - $state = _$state_; - QuerySet = _QuerySet_; - $interval = _$interval_; - workflowResultsService = _workflowResultsService_; - statusSocket = _statusSocket_; - - jobResultsService.getEvents.and - .returnValue(eventResolve); - eventQueue.populate.and - .returnValue(populateResolve); - - jobResultsService.getJobData = function() { - var deferred = $q.defer(); - deferred.resolve({}); - return deferred.promise; - }; - - $compile = _$compile_; - - jobResultsController = $controller('jobResultsController', { - $scope: $scope, - jobData: jobData, - jobDataOptions: jobDataOptions, - jobLabels: jobLabels, - jobFinished: jobFinished, - count: count, - ParseTypeChange: ParseTypeChange, - jobResultsService: jobResultsService, - eventQueue: eventQueue, - $compile: $compile, - $log: $log, - $q: q, - Dataset: Dataset, - Rest: Rest, - $state: $state, - QuerySet: QuerySet, - statusSocket: statusSocket - }); - }); - }; - - beforeEach(angular.mock.module('shared')); - - let bootstrapTest = () => { - provideVals(); - injectVals(); - }; - - describe('bootstrap resolve values on scope', () => { - beforeEach(() => { - bootstrapTest(); - }); - - it('should set values to scope based on resolve', () => { - expect($scope.job).toBe(jobData); - expect($scope.jobOptions).toBe(jobDataOptions.actions.GET); - expect($scope.labels).toBe(jobLabels); - }); - }); - - describe('getLinks()', () => { - beforeEach(() => { - jobData.related = { - "created_by": "api/v2/users/12", - "inventory": "api/v2/inventories/12", - "project": "api/v2/projects/12", - "credential": "api/v2/credentials/12", - "cloud_credential": "api/v2/credentials/13", - "network_credential": "api/v2/credentials/14", - }; - - jobData.summary_fields.inventory = { - id: 12, - kind: '' - }; - - bootstrapTest(); - }); - - it('should transform related links and set to scope var', () => { - expect($scope.created_by_link).toBe('/#/users/12'); - expect($scope.inventory_link).toBe('/#/inventories/inventory/12'); - expect($scope.project_link).toBe('/#/projects/12'); - expect($scope.machine_credential_link).toBe('/#/credentials/12'); - expect($scope.cloud_credential_link).toBe('/#/credentials/13'); - expect($scope.network_credential_link).toBe('/#/credentials/14'); - }); - }); - - describe('getLabels()', () => { - beforeEach(() => { - jobDataOptions.actions.GET = { - status: { - choices: [ - ["new", - "New"] - ] - }, - job_type: { - choices: [ - ["job", - "Playbook Run"] - ] - }, - verbosity: { - choices: [ - [0, - "0 (Normal)"] - ] - } - }; - jobData.status = "new"; - jobData.job_type = "job"; - jobData.verbosity = 0; - - bootstrapTest(); - }); - - it('should set scope variables based on options', () => { - $scope.job_status = jobData.status; - - $scope.$apply(); - expect($scope.status_label).toBe("New"); - expect($scope.type_label).toBe("Playbook Run"); - expect($scope.verbosity_label).toBe("0 (Normal)"); - }); - }); - - describe('elapsed timer', () => { - describe('job running', () => { - beforeEach(() => { - jobData.started = moment(); - jobData.status = 'running'; - - bootstrapTest(); - }); - - it('should start timer', () => { - expect(workflowResultsService.createOneSecondTimer).toHaveBeenCalled(); - }); - }); - - describe('job waiting', () => { - beforeEach(() => { - jobData.started = null; - jobData.status = 'waiting'; - - bootstrapTest(); - }); - - it('should not start timer', () => { - expect(workflowResultsService.createOneSecondTimer).not.toHaveBeenCalled(); - }); - }); - - describe('job transitions to running', () => { - beforeEach(() => { - jobData.started = null; - jobData.status = 'waiting'; - jobData.id = 13; - - bootstrapTest(); - - $rScope.$broadcast('ws-jobs', { unified_job_id: jobData.id, status: 'running' }); - }); - - it('should start timer', () => { - expect(workflowResultsService.createOneSecondTimer).toHaveBeenCalled(); - }); - - describe('job transitions from running to finished', () => { - it('should cleanup timer', () => { - $rScope.$broadcast('ws-jobs', { unified_job_id: jobData.id, status: 'successful' }); - expect(workflowResultsService.destroyTimer).toHaveBeenCalled(); - }); - }); - }); - }); - - describe('extra vars stuff', () => { - let extraVars = "foo"; - - beforeEach(() => { - jobData.extra_vars = extraVars; - - bootstrapTest(); - }); - - it('should have extra vars on scope', () => { - expect($scope.job.extra_vars).toBe(extraVars); - }); - - it('should call ParseVariableString and set to scope', () => { - expect(ParseVariableString) - .toHaveBeenCalledWith(extraVars); - expect($scope.variables).toBe(extraVars); - }); - - it('should set the parse type to yaml', () => { - expect($scope.parseType).toBe('yaml'); - }); - - it('should call ParseTypeChange with proper params', () => { - let params = { - scope: $scope, - field_id: 'pre-formatted-variables', - readOnly: true - }; - - expect(ParseTypeChange) - .toHaveBeenCalledWith(params); - }); - }); - - describe('$scope.toggleStdoutFullscreen', () => { - beforeEach(() => { - bootstrapTest(); - }); - - it('should toggle $scope.stdoutFullScreen', () => { - // essentially set to false - expect($scope.stdoutFullScreen).toBe(false); - - // toggle once to true - $scope.toggleStdoutFullscreen(); - expect($scope.stdoutFullScreen).toBe(true); - - // toggle again to false - $scope.toggleStdoutFullscreen(); - expect($scope.stdoutFullScreen).toBe(false); - }); - }); - - describe('$scope.deleteJob', () => { - beforeEach(() => { - bootstrapTest(); - }); - - it('should delete the job', () => { - let job = $scope.job; - $scope.deleteJob(); - expect(jobResultsService.deleteJob).toHaveBeenCalledWith(job); - }); - }); - - describe('$scope.cancelJob', () => { - beforeEach(() => { - bootstrapTest(); - }); - - it('should cancel the job', () => { - let job = $scope.job; - $scope.cancelJob(); - expect(jobResultsService.cancelJob).toHaveBeenCalledWith(job); - }); - }); - - describe('count stuff', () => { - beforeEach(() => { - count = { - val: { - ok: 1, - skipped: 2, - unreachable: 3, - failures: 4, - changed: 5 - }, - countFinished: true - }; - - bootstrapTest(); - }); - - it('should set count values to scope', () => { - expect($scope.count).toBe(count.val); - expect($scope.countFinished).toBe(true); - }); - - it('should find the hostCount based on the count', () => { - expect($scope.hostCount).toBe(15); - }); - }); - - describe('follow stuff - incomplete', () => { - beforeEach(() => { - jobFinished = false; - - bootstrapTest(); - }); - - it('should set followEngaged based on jobFinished incomplete', () => { - expect($scope.followEngaged).toBe(true); - }); - - it('should set followTooltip based on jobFinished incomplete', () => { - expect($scope.followTooltip).toBe("Currently following standard out as it comes in. Click to unfollow."); - }); - }); - - describe('follow stuff - finished', () => { - beforeEach(() => { - jobFinished = true; - - bootstrapTest(); - }); - - it('should set followEngaged based on jobFinished', () => { - expect($scope.followEngaged).toBe(false); - }); - - it('should set followTooltip based on jobFinished', () => { - expect($scope.followTooltip).toBe("Jump to last line of standard out."); - }); - }); - - describe('event stuff', () => { - beforeEach(() => { - jobData.id = 1; - jobData.related.job_events = "url"; - - bootstrapTest(); - }); - - xit('should make a rest call to get already completed events', () => { - expect(jobResultsService.getEvents).toHaveBeenCalledWith("url"); - }); - - xit('should call processEvent when receiving message', () => { - let eventPayload = {"foo": "bar"}; - $rScope.$broadcast('ws-job_events-1', eventPayload); - expect(eventQueue.populate).toHaveBeenCalledWith(eventPayload); - }); - - it('should set the job status on scope when receiving message', () => { - let eventPayload = { - unified_job_id: 1, - status: 'finished' - }; - $rScope.$broadcast('ws-jobs', eventPayload); - expect($scope.job_status).toBe(eventPayload.status); - }); - }); - - describe('getEvents and populate stuff', () => { - describe('getEvents', () => { - let event1 = { - event: 'foo' - }; - - let event2 = { - event_name: 'bar' - }; - - let event1Processed = { - event_name: 'foo' - }; - - beforeEach(() => { - eventResolve = { - results: [ - event1, - event2 - ] - }; - - bootstrapTest(); - - $scope.$apply(); - }); - - xit('should change the event name to event_name', () => { - expect(eventQueue.populate) - .toHaveBeenCalledWith(event1Processed); - }); - - xit('should pass through the event with event_name', () => { - expect(eventQueue.populate) - .toHaveBeenCalledWith(event2); - }); - - xit('should have called populate twice', () => { - expect(eventQueue.populate.calls.count()).toEqual(2); - }); - - // TODO: can't figure out how to a test of events.next... - // if you set events.next to true it causes the tests to - // stop running - }); - - describe('populate - start time', () => { - beforeEach(() => { - jobData.start = ""; - - populateResolve = { - startTime: 'foo', - changes: ['startTime'] - }; - - bootstrapTest(); - - $scope.$apply(); - }); - - xit('sets start time when passed as a change', () => { - expect($scope.job.start).toBe('foo'); - }); - }); - - describe('populate - start time already set', () => { - beforeEach(() => { - jobData.start = "bar"; - - populateResolve = { - startTime: 'foo', - changes: ['startTime'] - }; - - bootstrapTest(); - - $scope.$apply(); - }); - - xit('does not set start time because already set', () => { - expect($scope.job.start).toBe('bar'); - }); - }); - - describe('populate - count already received', () => { - let receiveCount = { - ok: 2, - skipped: 2, - unreachable: 2, - failures: 2, - changed: 2 - }; - - let alreadyCount = { - ok: 3, - skipped: 3, - unreachable: 3, - failures: 3, - changed: 3 - }; - - beforeEach(() => { - count.countFinished = true; - count.val = alreadyCount; - - populateResolve = { - count: receiveCount, - changes: ['count'] - }; - - bootstrapTest(); - - $scope.$apply(); - }); - - xit('count does not change', () => { - expect($scope.count).toBe(alreadyCount); - expect($scope.hostCount).toBe(15); - }); - }); - - describe('populate - playCount, taskCount and countFinished', () => { - beforeEach(() => { - - populateResolve = { - playCount: 12, - taskCount: 13, - changes: ['playCount', 'taskCount', 'countFinished'] - }; - - bootstrapTest(); - - $scope.$apply(); - }); - - xit('sets playCount', () => { - expect($scope.playCount).toBe(12); - }); - - xit('sets taskCount', () => { - expect($scope.taskCount).toBe(13); - }); - - xit('sets countFinished', () => { - expect($scope.countFinished).toBe(true); - }); - }); - - describe('populate - finishedTime', () => { - beforeEach(() => { - jobData.finished = ""; - - populateResolve = { - finishedTime: "finished_time", - changes: ['finishedTime'] - }; - - bootstrapTest(); - - $scope.$apply(); - }); - - xit('sets finished time and changes follow tooltip', () => { - expect($scope.job.finished).toBe('finished_time'); - expect($scope.jobFinished).toBe(true); - expect($scope.followTooltip) - .toBe("Jump to last line of standard out."); - }); - }); - - describe('populate - finishedTime when already finished', () => { - beforeEach(() => { - jobData.finished = "already_set"; - - populateResolve = { - finishedTime: "finished_time", - changes: ['finishedTime'] - }; - - bootstrapTest(); - - $scope.$apply(); - }); - - xit('does not set finished time because already set', () => { - expect($scope.job.finished).toBe('already_set'); - expect($scope.jobFinished).toBe(true); - expect($scope.followTooltip) - .toBe("Jump to last line of standard out."); - }); - }); - - describe('populate - stdout', () => { - beforeEach(() => { - - populateResolve = { - counter: 12, - stdout: "line", - changes: ['stdout'] - }; - - bootstrapTest(); - - spyOn($log, 'error'); - - $scope.followEngaged = true; - - $scope.$apply(); - }); - - xit('creates new child scope for the event', () => { - expect($scope.events[12].event).toBe(populateResolve); - - // in unit test, followScroll should not be defined as - // directive has not been instantiated - expect($log.error).toHaveBeenCalledWith("follow scroll undefined, standard out directive not loaded yet?"); - }); - }); - }); -}); diff --git a/awx/ui/test/spec/job-results/job-results.service-test.js b/awx/ui/test/spec/job-results/job-results.service-test.js deleted file mode 100644 index 1219f2e450..0000000000 --- a/awx/ui/test/spec/job-results/job-results.service-test.js +++ /dev/null @@ -1,50 +0,0 @@ -'use strict'; - -describe('jobResultsService', () => { - let jobResultsService; - - beforeEach(angular.mock.module('awApp')); - - beforeEach(angular.mock.inject(( _jobResultsService_) => { - jobResultsService = _jobResultsService_; - })); - - describe('getCountsFromStatsEvent()', () => { - it('properly counts hosts based on task state', () => { - let event_data = { - "skipped": { - "skipped-host": 5 // this host skipped all 5 tasks - }, - "ok": { - "ok-host": 5, // this host was ok on all 5 tasks - "changed-host": 4 // this host had 4 ok tasks, had 1 changed task - }, - "changed": { - "changed-host": 1 - }, - "failures": { - "failed-host": 1 // this host had a failed task - }, - "dark": { - "unreachable-host": 1 // this host was unreachable - }, - "processed": { - "ok-host": 1, - "changed-host": 1, - "skipped-host": 1, - "failed-host": 1, - "unreachable-host": 1 - }, - "playbook_uuid": "c23d8872-c92a-4e96-9f78-abe6fef38f33", - "playbook": "some_playbook.yml", - }; - expect(jobResultsService.getCountsFromStatsEvent(event_data)).toEqual({ - 'ok': 1, - 'skipped': 1, - 'unreachable': 1, - 'failures': 1, - 'changed': 1 - }); - }); - }); -}); diff --git a/awx/ui/test/spec/job-results/parse-stdout.service-test.js b/awx/ui/test/spec/job-results/parse-stdout.service-test.js deleted file mode 100644 index 86441c1ffa..0000000000 --- a/awx/ui/test/spec/job-results/parse-stdout.service-test.js +++ /dev/null @@ -1,212 +0,0 @@ -'use strict'; - -describe('parseStdoutService', () => { - let parseStdoutService, - log; - - beforeEach(angular.mock.module('awApp')); - - beforeEach(angular.mock.module('jobResults',($provide) => { - log = jasmine.createSpyObj('$log', [ - 'error' - ]); - - $provide.value('$log', log); - })); - - beforeEach(angular.mock.inject((_$log_, _parseStdoutService_) => { - parseStdoutService = _parseStdoutService_; - })); - - describe('prettify()', () => { - it('returns lines of stdout with styling classes', () => { - let line = "[0;32mok: [host-00]", - styledLine = 'ok: [host-00]'; - expect(parseStdoutService.prettify(line)).toBe(styledLine); - }); - - it('can return lines of stdout without styling classes', () => { - let line = "[0;32mok: [host-00][0m", - unstyled = "unstyled", - unstyledLine = 'ok: [host-00]'; - expect(parseStdoutService.prettify(line, unstyled)).toBe(unstyledLine); - }); - - it('can return empty strings', () => { - expect(parseStdoutService.prettify("")).toBe(""); - }); - }); - - describe('getLineClasses()', () => { - it('creates a string that is used as a class', () => { - let headerEvent = { - event_name: 'playbook_on_task_start', - event_data: { - play_uuid:"0f667a23-d9ab-4128-a735-80566bcdbca0", - task_uuid: "80dd087c-268b-45e8-9aab-1083bcfd9364" - } - }; - let lineNum = 3; - let line = "TASK [setup] *******************************************************************"; - let styledLine = " header_task header_task_80dd087c-268b-45e8-9aab-1083bcfd9364 actual_header play_0f667a23-d9ab-4128-a735-80566bcdbca0 line_num_3"; - expect(parseStdoutService.getLineClasses(headerEvent, line, lineNum)).toBe(styledLine); - }); - }); - - describe('getStartTime()', () => { - // TODO: the problem is that the date here calls moment, and thus - // the date will be timezone'd in the string (this could be - // different based on where you are) - xit('creates returns a badge with the start time of the event', () => { - let headerEvent = { - event_name: 'playbook_on_play_start', - created: "2016-11-22T21:15:54.736Z" - }; - - let line = "PLAY [add hosts to inventory] **************************************************"; - let badgeDiv = '
13:15:54
'; - expect(parseStdoutService.getStartTimeBadge(headerEvent, line)).toBe(badgeDiv); - }); - }); - - describe('getCollapseIcon()', () => { - let emptySpan = ` -`; - - it('returns empty expander for non-header event', () => { - let nonHeaderEvent = { - event_name: 'not_header', - start_line: 0, - end_line: 1, - stdout:"line1" - }; - expect(parseStdoutService.getCollapseIcon(nonHeaderEvent)) - .toBe(emptySpan); - }); - - it('returns collapse/decollapse icons for header events', () => { - let headerEvent = { - event_name: 'playbook_on_task_start', - start_line: 0, - end_line: 1, - stdout:"line1", - event_data: { - task_uuid: '1da9012d-18e6-4562-85cd-83cf10a97f86' - } - }; - let line = "TASK [setup] *******************************************************************"; - let expandSpan = ` - - - -`; - - expect(parseStdoutService.getCollapseIcon(headerEvent, line)) - .toBe(expandSpan); - }); - }); - - describe('getLineArr()', () => { - it('returns stdout in array format', () => { - let mockEvent = { - start_line: 12, - end_line: 14, - stdout: "line1\r\nline2\r\n" - }; - let expectedReturn = [[13, "line1"],[14, "line2"]]; - - let returnedEvent = parseStdoutService.getLineArr(mockEvent); - - expect(returnedEvent).toEqual(expectedReturn); - }); - - it('deals correctly with capped lines', () => { - let mockEvent = { - start_line: 7, - end_line: 11, - stdout: "a\r\nb\r\nc..." - }; - let expectedReturn = [[8, "a"],[9, "b"], [10,"c..."]]; - - let returnedEvent = parseStdoutService.getLineArr(mockEvent); - - expect(returnedEvent).toEqual(expectedReturn); - }); - }); - - describe('parseStdout()', () => { - let mockEvent = {"foo": "bar"}; - - it('calls functions', function() { - spyOn(parseStdoutService, 'getLineArr').and - .returnValue([[13, 'line1'], [14, 'line2']]); - spyOn(parseStdoutService, 'getLineClasses').and - .returnValue(""); - spyOn(parseStdoutService, 'getCollapseIcon').and - .returnValue(""); - spyOn(parseStdoutService, 'getAnchorTags').and - .returnValue(""); - spyOn(parseStdoutService, 'prettify').and - .returnValue("prettified_line"); - spyOn(parseStdoutService, 'getStartTimeBadge').and - .returnValue(""); - - parseStdoutService.parseStdout(mockEvent); - - expect(parseStdoutService.getLineArr) - .toHaveBeenCalledWith(mockEvent); - expect(parseStdoutService.getLineClasses) - .toHaveBeenCalledWith(mockEvent, 'line1', 13); - expect(parseStdoutService.getCollapseIcon) - .toHaveBeenCalledWith(mockEvent, 'line1'); - expect(parseStdoutService.getAnchorTags) - .toHaveBeenCalledWith(mockEvent); - expect(parseStdoutService.prettify) - .toHaveBeenCalledWith('line1'); - expect(parseStdoutService.getStartTimeBadge) - .toHaveBeenCalledWith(mockEvent, 'line1'); - - // get line arr should be called once for the event - expect(parseStdoutService.getLineArr.calls.count()) - .toBe(1); - - // other functions should be called twice (once for each - // line) - expect(parseStdoutService.getLineClasses.calls.count()) - .toBe(2); - expect(parseStdoutService.getCollapseIcon.calls.count()) - .toBe(2); - expect(parseStdoutService.getAnchorTags.calls.count()) - .toBe(2); - expect(parseStdoutService.prettify.calls.count()) - .toBe(2); - }); - - it('returns dom-ified lines', function() { - spyOn(parseStdoutService, 'getLineArr').and - .returnValue([[13, 'line1']]); - spyOn(parseStdoutService, 'getLineClasses').and - .returnValue("line_classes"); - spyOn(parseStdoutService, 'getCollapseIcon').and - .returnValue("collapse_icon_dom"); - spyOn(parseStdoutService, 'getAnchorTags').and - .returnValue(`" anchor_tag_dom`); - spyOn(parseStdoutService, 'prettify').and - .returnValue("prettified_line"); - spyOn(parseStdoutService, 'getStartTimeBadge').and - .returnValue(""); - - var returnedString = parseStdoutService.parseStdout(mockEvent); - - var expectedString = ` -
-
collapse_icon_dom13
-
prettified_line
-
`; - expect(returnedString).toBe(expectedString); - }); - }); -}); diff --git a/awx/ui/test/spec/karma.spec.js b/awx/ui/test/spec/karma.spec.js index abff3172f5..ae2dc8899d 100644 --- a/awx/ui/test/spec/karma.spec.js +++ b/awx/ui/test/spec/karma.spec.js @@ -13,6 +13,7 @@ module.exports = config => { frameworks: ['jasmine'], reporters: ['progress', 'junit'], files:[ + './polyfills.js', path.join(SRC_PATH, '**/*.html'), path.join(SRC_PATH, 'vendor.js'), path.join(NODE_MODULES, 'angular-mocks/angular-mocks.js'), diff --git a/awx/ui/test/spec/polyfills.js b/awx/ui/test/spec/polyfills.js new file mode 100644 index 0000000000..8b7342e5fd --- /dev/null +++ b/awx/ui/test/spec/polyfills.js @@ -0,0 +1,31 @@ +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill + +if (typeof Object.assign != 'function') { + // Must be writable: true, enumerable: false, configurable: true + Object.defineProperty(Object, "assign", { + value: function assign(target, varArgs) { // .length of function is 2 + 'use strict'; + if (target == null) { // TypeError if undefined or null + throw new TypeError('Cannot convert undefined or null to object'); + } + + var to = Object(target); + + for (var index = 1; index < arguments.length; index++) { + var nextSource = arguments[index]; + + if (nextSource != null) { // Skip over if undefined or null + for (var nextKey in nextSource) { + // Avoid bugs when hasOwnProperty is shadowed + if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { + to[nextKey] = nextSource[nextKey]; + } + } + } + } + return to; + }, + writable: true, + configurable: true + }); +} diff --git a/awx/ui/test/unit/karma.unit.js b/awx/ui/test/unit/karma.unit.js index 5ccd1fe151..d839aff5f2 100644 --- a/awx/ui/test/unit/karma.unit.js +++ b/awx/ui/test/unit/karma.unit.js @@ -14,6 +14,7 @@ module.exports = config => { browsers: ['PhantomJS'], reporters: ['progress', 'junit'], files: [ + './polyfills.js', path.join(SRC_PATH, 'vendor.js'), path.join(SRC_PATH, 'app.js'), path.join(SRC_PATH, '**/*.html'), diff --git a/awx/ui/test/unit/polyfills.js b/awx/ui/test/unit/polyfills.js new file mode 100644 index 0000000000..25b18055bd --- /dev/null +++ b/awx/ui/test/unit/polyfills.js @@ -0,0 +1,33 @@ +/* eslint-disable */ + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill + +if (typeof Object.assign != 'function') { + // Must be writable: true, enumerable: false, configurable: true + Object.defineProperty(Object, "assign", { + value: function assign(target, varArgs) { // .length of function is 2 + 'use strict'; + if (target == null) { // TypeError if undefined or null + throw new TypeError('Cannot convert undefined or null to object'); + } + + var to = Object(target); + + for (var index = 1; index < arguments.length; index++) { + var nextSource = arguments[index]; + + if (nextSource != null) { // Skip over if undefined or null + for (var nextKey in nextSource) { + // Avoid bugs when hasOwnProperty is shadowed + if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { + to[nextKey] = nextSource[nextKey]; + } + } + } + } + return to; + }, + writable: true, + configurable: true + }); +}