Compare commits

..

102 Commits

Author SHA1 Message Date
softwarefactory-project-zuul[bot]
e4e5d65a71 Merge pull request #8415 from ryanpetrello/release-15.0.1
bump version to 15.0.1

Reviewed-by: Bianca Henderson <beeankha@gmail.com>
             https://github.com/beeankha
2020-10-20 14:49:59 +00:00
Ryan Petrello
67a5ad7dd6 Bump version to 15.0.1 2020-10-20 09:39:43 -04:00
Ryan Petrello
06b1243857 Merge pull request #8396 from shanemcd/retry-galaxy
Makefile logic to retry failed galaxy installs
2020-10-20 09:37:47 -04:00
softwarefactory-project-zuul[bot]
fb2cea7274 Merge pull request #8416 from ryanpetrello/wf-approval-201
make workflow approval creation return an HTTP 201, not 200 OK

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-19 20:51:32 +00:00
Ryan Petrello
57b2cd402b make workflow approval creation return an HTTP 201, not 200 OK
see: e16a910062 (diff-67c0fe4fc2a405ad611e42d1457e8aa5)
2020-10-19 16:20:40 -04:00
softwarefactory-project-zuul[bot]
d7864c58c1 Merge pull request #8403 from chrismeyersfsu/fix-same_jt_abuse_devel
Improve general performance for a variety of high-load job launch use cases

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-19 19:38:32 +00:00
softwarefactory-project-zuul[bot]
933de6aa97 Merge pull request #8312 from thedoubl3j/requirement_yaml
related #4540 updating project_update.yml to accept yaml extension 

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-19 19:32:01 +00:00
softwarefactory-project-zuul[bot]
ed5074c09c Merge pull request #8408 from marcolussetti/fix-docker-compose-install
Point installer to community.general.docker_image

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-19 18:47:57 +00:00
softwarefactory-project-zuul[bot]
5c751f3f8e Merge pull request #8230 from nixocio/ui_issue_7751
Add username to tooltip

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-19 17:20:01 +00:00
Jake Jackson
be6ed623f6 updating project_update.yml to accept yaml extension 2020-10-19 11:23:44 -04:00
Chris Meyers
2eac5a8873 reduce per-job database query count
* Do not query the database for the set of Instance that belong to the
group for which we are trying to fit a job on, for each job.
* Instead, cache the set of instances per-instance group.
2020-10-19 10:54:56 -04:00
Chris Meyers
09a0448c3e reduce parent->child lock contention
* We update the parent unified job template to point at new jobs
created. We also update a similar foreign key when the job finishes
running. This causes lock contention when the job template is
allow_simultaneous and there are a lot of jobs from that job template
running in parallel. I've seen as bad as 5 minutes waiting for the lock
when a job finishes.
* This change moves the parent->child update to OUTSIDE of the
transaction if the job is allow_simultaneous (inherited from the parent
unified job). We sacrafice a bit of correctness for performance. The
logic is, if you are launching 1,000 parallel jobs do you really care
that the job template contains a pointer to the last one you launched?
Probably not. If you do, you can always query jobs related to the job
template sorted by created time.
2020-10-19 10:54:51 -04:00
Marco Lussetti
9818440d0f curtail change to ansible >= 2.10 per feedback 2020-10-19 06:28:50 -07:00
Marco Lussetti
8217d14e36 Point installer to community.general.docker_image
Fixes issue in Ansible 2.10.2 where docker_image command is not found.
2020-10-15 20:38:29 -07:00
softwarefactory-project-zuul[bot]
28e792056d Merge pull request #8398 from john-westcott-iv/fix_stack_trace
Fixing stack trace from an error not having a detail

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-15 21:37:49 +00:00
softwarefactory-project-zuul[bot]
59b5104431 Merge pull request #8397 from john-westcott-iv/inv_src_get_one_fix
Fixing get_one calls in tower_inventory_source_update

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-15 21:33:19 +00:00
John Westcott IV
c759c83daf Fixing stack trace from an error not having a detail 2020-10-15 15:09:11 -04:00
John Westcott IV
407356239b Fixing get_one calls in tower_inventory_source_update 2020-10-15 12:40:31 -04:00
softwarefactory-project-zuul[bot]
1d1e1787c4 Merge pull request #8395 from jakemcdermott/current-year
Use current year in about modal

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-15 15:47:06 +00:00
Shane McDonald
df43221c24 Makefile logic to retry failed galaxy installs 2020-10-15 10:43:48 -04:00
Jake McDermott
09c961fc56 Use current year in about modal 2020-10-15 10:02:27 -04:00
softwarefactory-project-zuul[bot]
cd72bb6cb2 Merge pull request #8393 from mabashian/remove-mathjs
Remove mathjs as a dependency

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-14 18:36:01 +00:00
softwarefactory-project-zuul[bot]
26616a409f Merge pull request #8386 from john-westcott-iv/issue_8338
Fixing resetting of variables and adding unit test for condition

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-14 18:11:46 +00:00
Jake McDermott
3c71ab1bd7 Remove math licenses 2020-10-14 14:07:53 -04:00
softwarefactory-project-zuul[bot]
47cffd3c02 Merge pull request #8392 from mabashian/upgrade-angular-1.8.1
Upgrade angular to 1.8.1

Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
             https://github.com/jakemcdermott
2020-10-14 17:17:29 +00:00
softwarefactory-project-zuul[bot]
63249dc241 Merge pull request #8351 from nixocio/ui_issue_7721
Update AddDropDownButton to show down caret

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-14 17:13:13 +00:00
John Westcott IV
0d8b1d172c Fixing linting and spelling 2020-10-14 12:39:15 -04:00
mabashian
851c802ea8 Remove mathjs as a dependency 2020-10-14 12:18:51 -04:00
mabashian
1d65b8cd53 Upgrade angular to 1.8.1 2020-10-14 11:16:51 -04:00
John Westcott IV
f0449adcf8 Fixing resetting of variables and adding unit test for condition 2020-10-13 16:46:05 -04:00
softwarefactory-project-zuul[bot]
1ecd38a4ee Merge pull request #8369 from nixocio/ui_issue_5685
Add general updates for User screen

Reviewed-by: Kersom
             https://github.com/nixocio
2020-10-13 18:56:53 +00:00
softwarefactory-project-zuul[bot]
defb65d3d5 Merge pull request #8319 from asohh/devel
Added Groups/Hosts/Sources count to inventory list rows #7674

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-13 15:10:36 +00:00
softwarefactory-project-zuul[bot]
f283a6ef68 Merge pull request #8382 from ryanpetrello/cli-docs-clarification
clarify the instructions for building CLI docs

Reviewed-by: Christian Adams <rooftopcellist@gmail.com>
             https://github.com/rooftopcellist
2020-10-13 13:44:11 +00:00
softwarefactory-project-zuul[bot]
f9e8c03ec6 Merge pull request #7090 from jladdjr/updates_to_same_inv_should_happen_serially
Updates to a given inventory should happen serially

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-13 13:28:01 +00:00
Ryan Petrello
77d0958490 clarify the instructios for building CLI docs 2020-10-13 09:04:35 -04:00
softwarefactory-project-zuul[bot]
058049aa1b Merge pull request #8376 from ryanpetrello/15-0-0-galaxy-note
Clarify behavioral changes from content syncing behavior in 15.0.0+

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-12 20:22:37 +00:00
softwarefactory-project-zuul[bot]
bad064b577 Merge pull request #8370 from ryanpetrello/fix-bad-extra-vars-warning
fix a confusing error message when extra vars aren't valid JSON/YAML

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-12 20:17:15 +00:00
Ryan Petrello
faf0fa9040 Clarify behavioral changes from content syncing behavior in 15.0.0+
see: https://github.com/ansible/awx/issues/8341
2020-10-12 15:51:08 -04:00
Ryan Petrello
0cdcbdfea6 fix a confusing error message when extra vars aren't valid JSON/YAML
see: https://github.com/ansible/awx/issues/8359
2020-10-12 12:56:13 -04:00
Jim Ladd
05ac2c1ec2 Updates to a given inventory should happen serially 2020-10-12 09:34:37 -07:00
nixocio
1dd7651d49 Add general updates for User screen
* Add type of login used as part of UserListItem.
* Add type of login used as part of UserDetail.
* Hide password field, UserForm, in case login method is LDAP or Social.
* Make username field, UserForm, not required in case login is LDAP or
Social.

See: https://github.com/ansible/awx/issues/5685
2020-10-12 12:03:31 -04:00
softwarefactory-project-zuul[bot]
49c0b77c60 Merge pull request #8326 from wenottingham/param-a-lot
Fix 500 when required LDAP group parameters aren't set.

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-12 15:58:27 +00:00
softwarefactory-project-zuul[bot]
119c907279 Merge pull request #8353 from jbradberry/zedr-wfjt-inventories
Zedr wfjt inventories

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-12 15:55:01 +00:00
Adrian Sorge
8f37afeec4 Changed Listgroup to div 2020-10-10 14:21:19 +01:00
Jeff Bradberry
a07dabae9e Attempt to infer related pages on export when the link is missing 2020-10-09 14:06:28 -04:00
softwarefactory-project-zuul[bot]
e6c124962b Merge pull request #8352 from nixocio/ui_issue_6857
Mark missing words for translation

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-09 16:01:40 +00:00
softwarefactory-project-zuul[bot]
bb15132031 Merge pull request #8306 from thedoubl3j/sanity_ignore
fixing quotes to not fail smart quotes sanity test

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-09 15:16:13 +00:00
softwarefactory-project-zuul[bot]
0d4226a903 Merge pull request #8342 from AlexSCorey/8283-InventoryHelperMisalignment
Fixes invalid text location

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-08 19:49:43 +00:00
nixocio
d149e23170 Mark missing words for translation
Add missing words for translation.

`...more`, and `Show Less` were already marked for translation in a
previous PR, since this code is shared as part of the `ChipGroup` code.

See: https://github.com/ansible/awx/issues/6857
2020-10-08 11:25:24 -04:00
Rigel Di Scala
32c08a09c3 Serialize Workflow Job Template inventories by natural key - related #7798
This changeset introduces two changes:

 1. Update the API representation of Workflow Job Templates to use the
 natural key of the Inventory type instead of its id;
 2. Override the related property of the CLI's WorkflowJobTemplate page
 type to patch the related references during the export process,
 allowing the resource to be serialised using the natural key of the
 Inventory type instead of the id.

Change n.2 is a workaround that is used when exporting resources from
AWX/Tower instances that don't have change n.1. It can be removed in the
future.
2020-10-08 10:21:00 -04:00
nixocio
1fbcd7e434 Update AddDropDownButton to show down caret
Update AddDropDownButton to show down caret

See: https://github.com/ansible/awx/issues/7721
2020-10-08 09:17:50 -04:00
softwarefactory-project-zuul[bot]
a1700404cd Merge pull request #8243 from AlexSCorey/AdHocCommandsOnLists
Adds Ad Hoc Commands To Remaining Lists

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-07 21:55:14 +00:00
Alex Corey
b04be850b5 fixes bug where one can launch erronously, adds tests for that bug 2020-10-07 12:18:13 -04:00
softwarefactory-project-zuul[bot]
503886b704 Merge pull request #8325 from rooftopcellist/i18n_devel_ja_oct5
UI translation strings for devel branch

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-07 13:50:07 +00:00
Alex Corey
ee28dff7cb fixes invalid text location 2020-10-07 09:11:44 -04:00
softwarefactory-project-zuul[bot]
37b3cc72b2 Merge pull request #8235 from AlexSCorey/5913-RefactorJTPOL
Restructures Job Template POL and renames useSteps

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-06 18:47:12 +00:00
Bill Nottingham
bc22fa56dc Fix 500 when required LDAP group parameters aren't set. 2020-10-05 22:15:20 -07:00
softwarefactory-project-zuul[bot]
4af4252604 Merge pull request #8298 from keithjgrant/7677-a11y-text
Add accessibility labels to job status/cloud sync icons

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-05 21:54:01 +00:00
Alex Corey
b1a1c82169 fixes unresponsive clear all on survey step 2020-10-05 17:13:52 -04:00
Alex Corey
935c7a5328 refactors useSteps to allow each step to handle errors 2020-10-05 17:13:52 -04:00
ansible-translation-bot
792662f3d6 UI translation strings for devel branch 2020-10-05 16:02:24 -04:00
Adrian Sorge
d4e4e3020c changes of npm run prettier 2020-10-05 18:02:54 +01:00
Adrian Sorge
7b13a42daa Fixed Suggestions from Code Review 2020-10-05 17:49:21 +01:00
softwarefactory-project-zuul[bot]
ac105ccd05 Merge pull request #8315 from jakemcdermott/deps-autofix-10-02
Address dependency warning

Reviewed-by: John Hill <johill@redhat.com>
             https://github.com/unlikelyzero
2020-10-05 16:00:12 +00:00
Keith Grant
b7070b7a72 add aria-hidden to Running/Waiting job icons 2020-10-05 08:23:41 -07:00
Adrian Sorge
70141f3d77 Added Groups/Hosts/Sources count to inventory list rows #7674 2020-10-04 15:17:25 +01:00
Jake McDermott
4907aa35a9 Update vulnerable dependency 2020-10-02 20:35:30 -04:00
Alex Corey
f051c4d58a fixes bug with disappearing modal and arguments field validation 2020-10-02 17:17:49 -04:00
softwarefactory-project-zuul[bot]
bd224a75db Merge pull request #8282 from marshmalien/8044-inventory-file-bug
Fix inventory file dropdown placeholder value

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-02 20:35:08 +00:00
Alex Corey
eb2d7c6a77 Adds Ad Hoc Commands 2020-10-02 15:41:25 -04:00
softwarefactory-project-zuul[bot]
0b824ee058 Merge pull request #8313 from ryanpetrello/fix-garbled-unicode-in-notifications
fix a display bug in notification bodies with unicode characters

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-02 19:30:21 +00:00
Ryan Petrello
30b6fd27b3 fix a display bug in notification bodies with unicode characters
see: https://github.com/ansible/awx/issues/7400
2020-10-02 14:37:25 -04:00
softwarefactory-project-zuul[bot]
1792b1350c Merge pull request #8308 from rebeccahhh/awxkit_errormessage_correction
correct error message when calling tower_AWXKit collections module and venv doesn't have it

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-02 16:56:03 +00:00
softwarefactory-project-zuul[bot]
6a61b7ce49 Merge pull request #8307 from ryanpetrello/wf-approval-delete
fix a bug that prevents deletion of workflow approval records

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-01 22:43:32 +00:00
Rebeccah
5df37d4279 switched exit_json to be fail_json so it didn't trigger an error when attempting to utilize the missing_required_lib from ansible. Additionally fixed it for the second usage for correct usage. Given that it is an exception the correct one would not be to exit without failure, as exit_json does, but instead to use fail_json and be able to present the error 2020-10-01 18:28:26 -04:00
Ryan Petrello
64485c1066 fix a bug that prevents deletion of workflow approval records
see: https://github.com/ansible/awx/issues/8305
2020-10-01 16:06:53 -04:00
Jake Jackson
427e1cd214 fixing quotes to not fail smart quotes sanity test 2020-10-01 15:32:43 -04:00
softwarefactory-project-zuul[bot]
796a61da86 Merge pull request #8299 from jakemcdermott/fix-8154
Force logout and clear before social auth login

Reviewed-by: Jake McDermott <yo@jakemcdermott.me>
             https://github.com/jakemcdermott
2020-10-01 17:47:12 +00:00
Keith Grant
afe09695d4 update tests 2020-10-01 09:55:17 -07:00
softwarefactory-project-zuul[bot]
f774ef8635 Merge pull request #8304 from jakemcdermott/fix-7892
Remove self-closing tags from webhook cred lookup

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-10-01 16:34:00 +00:00
Jake McDermott
bfd224eb7c Remove self-closing tags from webhook cred lookup 2020-10-01 11:44:23 -04:00
Jake McDermott
7479b9faca Force logout and clear before social auth login 2020-10-01 11:15:46 -04:00
Keith Grant
e204325d1d add a11y label to SyncStatusIndicator 2020-09-30 14:36:07 -07:00
Keith Grant
c75c6ae03d add a11y label to StatusIcon 2020-09-30 14:32:45 -07:00
softwarefactory-project-zuul[bot]
1b6acdf84d Merge pull request #8274 from beeankha/add_collection_test_readme
Add New Testing Document for AWX Collections

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-09-30 19:17:46 +00:00
softwarefactory-project-zuul[bot]
5c0432b979 Merge pull request #8186 from marshmalien/setting-details
Add setting details and unit tests

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-09-30 19:01:30 +00:00
softwarefactory-project-zuul[bot]
c7869f0408 Merge pull request #8280 from nixocio/ui_issue_8189
Make ContainerGroupDetails a bit more robust

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-09-30 19:00:25 +00:00
Marliana Lara
5650344fe8 Fix inventory file dropdown placeholder value 2020-09-30 14:08:08 -04:00
nixocio
ae29eb9673 Make ContainerGroupDetails a bit more robust
In order to create a container group is necessary to provide a
credential.

See: https://github.com/ansible/awx/issues/8184

This change makes the code related to display the credential as part of
the container group details a bit more robust. Avoiding to attempt to
show a non-existent credential - what is not supposed to exist.

Closes: https://github.com/ansible/awx/issues/8199
2020-09-30 13:37:38 -04:00
softwarefactory-project-zuul[bot]
dc997346b6 Merge pull request #8279 from ryanpetrello/drf-release-note
Clarify changelog addition to address CVE-2020-25626

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-09-30 16:55:45 +00:00
Ryan Petrello
70a371b212 s/Tower/AWX 2020-09-30 12:28:11 -04:00
Ryan Petrello
429e752c26 Clarify changelog addition to address CVE-2020-25626 2020-09-30 12:20:43 -04:00
softwarefactory-project-zuul[bot]
679256fd25 Merge pull request #8239 from nixocio/ui_issue_8184
Add validate for `CredentialLookup` using required prop

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
2020-09-30 15:24:35 +00:00
beeankha
1bb6601782 Add New Testing Doc for AWX Collections 2020-09-30 10:43:10 -04:00
nixocio
98b9d4358d Add username to tooltip
Add username to tooltip when user cannot be deleted.

See: https://github.com/ansible/awx/issues/7751
2020-09-28 13:22:01 -04:00
Marliana Lara
bd3c4ca50f Fix aria label routed tab bug value "Object Object" 2020-09-28 12:11:10 -04:00
nixocio
1aa90af342 Add validate for CredentialLookup using required prop
When using `CredentialLookup` with required prop. The `validate`
argument must be provided to the `useField`.

See: https://github.com/ansible/awx/issues/8184
2020-09-25 14:19:51 -04:00
Marliana Lara
558dfb685e Add a new key "unit" to api setting fields
* Add detail popover
* Fix broken redirects
* Add additional id and data-cy attributes to Detail components
* Remove galaxy fields from job settings
2020-09-25 14:00:27 -04:00
Marliana Lara
a69a40a429 Rename radius to correct name 2020-09-23 15:00:06 -04:00
Marliana Lara
749afd53a1 Rename radius to incorrect name 2020-09-23 14:59:00 -04:00
Marliana Lara
7dc1157f69 Rename files to incorrect name
Rename files to correct name
2020-09-23 14:54:29 -04:00
Marliana Lara
b768b0222e Add setting details and unit tests 2020-09-23 14:54:26 -04:00
202 changed files with 16833 additions and 7125 deletions

View File

@@ -2,22 +2,39 @@
This is a list of high-level changes for each release of AWX. A full list of commits can be found at `https://github.com/ansible/awx/releases/tag/<version>`.
## 15.0.1 (October 20, 2020)
- Added several optimizations to improve performance for a variety of high-load simultaneous job launch use cases https://github.com/ansible/awx/pull/8403
- Added the ability to source roles and collections from requirements.yaml files (not just requirements.yml) - https://github.com/ansible/awx/issues/4540
- awx.awx collection modules now provide a clearer error message for incompatible versions of awxkit - https://github.com/ansible/awx/issues/8127
- Fixed a bug in notification messages that contain certain unicode characters - https://github.com/ansible/awx/issues/7400
- Fixed a bug that prevents the deletion of Workflow Approval records - https://github.com/ansible/awx/issues/8305
- Fixed a bug that broke the selection of webhook credentials - https://github.com/ansible/awx/issues/7892
- Fixed a bug which can cause confusing behavior for social auth logins across distinct browser tabs - https://github.com/ansible/awx/issues/8154
- Fixed several bugs in the output of Workflow Job Templates using the `awx export` tool - https://github.com/ansible/awx/issues/7798 https://github.com/ansible/awx/pull/7847
- Fixed a race condition that can lead to missing hosts when running parallel inventory syncs - https://github.com/ansible/awx/issues/5571
- Fixed an HTTP 500 error when certain LDAP group parameters aren't properly set - https://github.com/ansible/awx/issues/7622
- Updated a few dependencies in response to several CVEs:
* CVE-2020-7720
* CVE-2020-7743
* CVE-2020-7676
## 15.0.0 (September 30, 2020)
- Added improved support for fetching Ansible collections from private Galaxy content sources (such as https://github.com/ansible/galaxy_ng) - https://github.com/ansible/awx/issues/7813
**Note:** as part of this change, new Organizations created in the AWX API will _no longer_ automatically synchronize roles and collections from galaxy.ansible.com by default. More details on this change can be found at: https://github.com/ansible/awx/issues/8341#issuecomment-707310633
- AWX now utilizes a version of certifi that auto-discovers certificates in the system certificate store - https://github.com/ansible/awx/pull/8242
- Added support for arbitrary custom inventory plugin configuration: https://github.com/ansible/awx/issues/5150
- Added improved support for fetching Ansible collections from private Galaxy content sources (such as https://github.com/ansible/galaxy_ng) - https://github.com/ansible/awx/issues/7813
- Added an optional setting to disable the auto-creation of organizations and teams on successful SAML login. - https://github.com/ansible/awx/pull/8069
- Added a number of optimizations to Ansible Tower's callback receiver to improve the speed of stdout processing for simultaneous playbooks runs - https://github.com/ansible/awx/pull/8193 https://github.com/ansible/awx/pull/8191
- Added a number of optimizations to AWX's callback receiver to improve the speed of stdout processing for simultaneous playbooks runs - https://github.com/ansible/awx/pull/8193 https://github.com/ansible/awx/pull/8191
- Added the ability to use `!include` and `!import` constructors when constructing YAML for use with the AWX CLI - https://github.com/ansible/awx/issues/8135
- Fixed a bug that prevented certain users from being able to edit approval nodes in Workflows - https://github.com/ansible/awx/pull/8253
- Fixed a bug that broke password prompting for credentials in certain cases - https://github.com/ansible/awx/issues/8202
- Fixed a bug which can cause PostgreSQL deadlocks when running many parallel playbooks against large shared inventories - https://github.com/ansible/awx/issues/8145
- Fixed a bug which can cause delays in Ansible Tower's task manager when large numbers of simultaneous jobs are scheduled - https://github.com/ansible/awx/issues/7655
- Fixed a bug which can cause delays in AWX's task manager when large numbers of simultaneous jobs are scheduled - https://github.com/ansible/awx/issues/7655
- Fixed a bug which can cause certain scheduled jobs - those that run every X minute(s) or hour(s) - to fail to run at the proper time - https://github.com/ansible/awx/issues/8071
- Fixed a performance issue for playbooks that store large amounts of data using the `set_stats` module - https://github.com/ansible/awx/issues/8006
- Fixed a bug related to AWX's handling of the auth_path argument for the HashiVault KeyValue credential plugin - https://github.com/ansible/awx/pull/7991
- Fixed a bug that broke support for Remote Archive SCM Type project syncs on platforms that utilize Python2 - https://github.com/ansible/awx/pull/8057
- Updated to the latest version of Django Rest Framework.
- Updated to the latest version of Django Rest Framework to address CVE-2020-25626
- Updated to the latest version of Django to address CVE-2020-24583 and CVE-2020-24584
- Updated to the latest verson of channels_redis to address a bug that slowly causes Daphne processes to leak memory over time - https://github.com/django/channels_redis/issues/212

View File

@@ -78,6 +78,8 @@ Before you can run a deployment, you'll need the following installed in your loc
- [docker](https://pypi.org/project/docker/) Python module
+ This is incompatible with `docker-py`. If you have previously installed `docker-py`, please uninstall it.
+ We use this module instead of `docker-py` because it is what the `docker-compose` Python module requires.
- [community.general.docker_image collection](https://docs.ansible.com/ansible/latest/collections/community/general/docker_image_module.html)
+ This is only required if you are using Ansible >= 2.10
- [GNU Make](https://www.gnu.org/software/make/)
- [Git](https://git-scm.com/) Requires Version 1.8.4+
- Python 3.6+
@@ -662,6 +664,7 @@ The preferred way to install the AWX CLI is through pip directly from PyPI:
To build the docs, spin up a real AWX server, `pip3 install sphinx sphinxcontrib-autoprogram`, and run:
~ cd awxkit/awxkit/cli/docs
~ TOWER_HOST=https://awx.example.org TOWER_USERNAME=example TOWER_PASSWORD=secret make clean html
~ cd build/html/ && python -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ..

View File

@@ -214,7 +214,11 @@ requirements_awx_dev:
requirements_collections:
mkdir -p $(COLLECTION_BASE)
ansible-galaxy collection install -r requirements/collections_requirements.yml -p $(COLLECTION_BASE)
n=0; \
until [ "$$n" -ge 5 ]; do \
ansible-galaxy collection install -r requirements/collections_requirements.yml -p $(COLLECTION_BASE) && break; \
n=$$((n+1)); \
done
requirements: requirements_ansible requirements_awx requirements_collections

View File

@@ -1 +1 @@
15.0.0
15.0.1

View File

@@ -16,6 +16,7 @@ register(
help_text=_('Number of seconds that a user is inactive before they will need to login again.'),
category=_('Authentication'),
category_slug='authentication',
unit=_('seconds'),
)
register(
'SESSIONS_PER_USER',
@@ -49,6 +50,7 @@ register(
'in the number of seconds.'),
category=_('Authentication'),
category_slug='authentication',
unit=_('seconds'),
)
register(
'ALLOW_OAUTH2_FOR_EXTERNAL_USERS',

View File

@@ -39,7 +39,7 @@ class Metadata(metadata.SimpleMetadata):
'min_length', 'max_length',
'min_value', 'max_value',
'category', 'category_slug',
'defined_in_file'
'defined_in_file', 'unit',
]
for attr in text_attrs:

View File

@@ -3438,6 +3438,12 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk})
if obj.webhook_credential_id:
res['webhook_credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.webhook_credential_id})
if obj.inventory_id:
res['inventory'] = self.reverse(
'api:inventory_detail', kwargs={
'pk': obj.inventory_id
}
)
return res
def validate_extra_vars(self, value):

View File

@@ -3043,7 +3043,7 @@ class WorkflowJobTemplateNodeCreateApproval(RetrieveAPIView):
approval_template,
context=self.get_serializer_context()
).data
return Response(data, status=status.HTTP_200_OK)
return Response(data, status=status.HTTP_201_CREATED)
def check_permissions(self, request):
obj = self.get_object().workflow_job_template

View File

@@ -129,12 +129,14 @@ class SettingsRegistry(object):
placeholder = field_kwargs.pop('placeholder', empty)
encrypted = bool(field_kwargs.pop('encrypted', False))
defined_in_file = bool(field_kwargs.pop('defined_in_file', False))
unit = field_kwargs.pop('unit', None)
if getattr(field_kwargs.get('child', None), 'source', None) is not None:
field_kwargs['child'].source = None
field_instance = field_class(**field_kwargs)
field_instance.category_slug = category_slug
field_instance.category = category
field_instance.depends_on = depends_on
field_instance.unit = unit
if placeholder is not empty:
field_instance.placeholder = placeholder
field_instance.defined_in_file = defined_in_file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -148,7 +148,7 @@ register(
default='https://example.com',
schemes=('http', 'https'),
allow_plain_hostname=True, # Allow hostname only without TLD.
label=_('Automation Analytics upload URL.'),
label=_('Automation Analytics upload URL'),
help_text=_('This setting is used to to configure data collection for the Automation Analytics dashboard'),
category=_('System'),
category_slug='system',
@@ -253,6 +253,7 @@ register(
help_text=_('The number of seconds to sleep between status checks for jobs running on isolated instances.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@@ -264,6 +265,7 @@ register(
'This includes the time needed to copy source control files (playbooks) to the isolated instance.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@@ -276,6 +278,7 @@ register(
'Value should be substantially greater than expected network latency.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@@ -497,6 +500,7 @@ register(
'timeout should be imposed. A timeout set on an individual job template will override this.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@@ -509,6 +513,7 @@ register(
'timeout should be imposed. A timeout set on an individual inventory source will override this.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@@ -521,6 +526,7 @@ register(
'timeout should be imposed. A timeout set on an individual project will override this.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@@ -535,6 +541,7 @@ register(
'Use a value of 0 to indicate that no timeout should be imposed.'),
category=_('Jobs'),
category_slug='jobs',
unit=_('seconds'),
)
register(
@@ -542,7 +549,7 @@ register(
field_class=fields.IntegerField,
allow_null=False,
default=200,
label=_('Maximum number of forks per job.'),
label=_('Maximum number of forks per job'),
help_text=_('Saving a Job Template with more than this number of forks will result in an error. '
'When set to 0, no limit is applied.'),
category=_('Jobs'),
@@ -672,6 +679,7 @@ register(
'aggregator protocols.'),
category=_('Logging'),
category_slug='logging',
unit=_('seconds'),
)
register(
'LOG_AGGREGATOR_VERIFY_CERT',
@@ -752,7 +760,8 @@ register(
default=14400, # every 4 hours
min_value=1800, # every 30 minutes
category=_('System'),
category_slug='system'
category_slug='system',
unit=_('seconds'),
)

View File

@@ -874,21 +874,20 @@ class Command(BaseCommand):
Load inventory from in-memory groups to the database, overwriting or
merging as appropriate.
'''
with advisory_lock('inventory_{}_update'.format(self.inventory.id)):
# FIXME: Attribute changes to superuser?
# Perform __in queries in batches (mainly for unit tests using SQLite).
self._batch_size = 500
self._build_db_instance_id_map()
self._build_mem_instance_id_map()
if self.overwrite:
self._delete_hosts()
self._delete_groups()
self._delete_group_children_and_hosts()
self._update_inventory()
self._create_update_groups()
self._create_update_hosts()
self._create_update_group_children()
self._create_update_group_hosts()
# FIXME: Attribute changes to superuser?
# Perform __in queries in batches (mainly for unit tests using SQLite).
self._batch_size = 500
self._build_db_instance_id_map()
self._build_mem_instance_id_map()
if self.overwrite:
self._delete_hosts()
self._delete_groups()
self._delete_group_children_and_hosts()
self._update_inventory()
self._create_update_groups()
self._create_update_hosts()
self._create_update_group_children()
self._create_update_group_hosts()
def remote_tower_license_compare(self, local_license_type):
# this requires https://github.com/ansible/ansible/pull/52747
@@ -998,143 +997,144 @@ class Command(BaseCommand):
raise CommandError('invalid regular expression for --host-filter')
begin = time.time()
self.load_inventory_from_database()
with advisory_lock('inventory_{}_update'.format(self.inventory_id)):
self.load_inventory_from_database()
try:
self.check_license()
except CommandError as e:
self.mark_license_failure(save=True)
raise e
try:
self.check_license()
except CommandError as e:
self.mark_license_failure(save=True)
raise e
try:
# Check the per-org host limits
self.check_org_host_limit()
except CommandError as e:
self.mark_org_limits_failure(save=True)
raise e
status, tb, exc = 'error', '', None
try:
if settings.SQL_DEBUG:
queries_before = len(connection.queries)
# Update inventory update for this command line invocation.
with ignore_inventory_computed_fields():
iu = self.inventory_update
if iu.status != 'running':
with transaction.atomic():
self.inventory_update.status = 'running'
self.inventory_update.save()
source = self.get_source_absolute_path(self.source)
data = AnsibleInventoryLoader(source=source, is_custom=self.is_custom,
venv_path=venv_path, verbosity=self.verbosity).load()
logger.debug('Finished loading from source: %s', source)
logger.info('Processing JSON output...')
inventory = MemInventory(
group_filter_re=self.group_filter_re, host_filter_re=self.host_filter_re)
inventory = dict_to_mem_data(data, inventory=inventory)
del data # forget dict from import, could be large
logger.info('Loaded %d groups, %d hosts', len(inventory.all_group.all_groups),
len(inventory.all_group.all_hosts))
if self.exclude_empty_groups:
inventory.delete_empty_groups()
self.all_group = inventory.all_group
if settings.DEBUG:
# depending on inventory source, this output can be
# *exceedingly* verbose - crawling a deeply nested
# inventory/group data structure and printing metadata about
# each host and its memberships
#
# it's easy for this scale of data to overwhelm pexpect,
# (and it's likely only useful for purposes of debugging the
# actual inventory import code), so only print it if we have to:
# https://github.com/ansible/ansible-tower/issues/7414#issuecomment-321615104
self.all_group.debug_tree()
with batch_role_ancestor_rebuilding():
# If using with transaction.atomic() with try ... catch,
# with transaction.atomic() must be inside the try section of the code as per Django docs
try:
# Ensure that this is managed as an atomic SQL transaction,
# and thus properly rolled back if there is an issue.
with transaction.atomic():
# Merge/overwrite inventory into database.
if settings.SQL_DEBUG:
logger.warning('loading into database...')
with ignore_inventory_computed_fields():
if getattr(settings, 'ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC', True):
self.load_into_database()
else:
with disable_activity_stream():
self.load_into_database()
if settings.SQL_DEBUG:
queries_before2 = len(connection.queries)
self.inventory.update_computed_fields()
if settings.SQL_DEBUG:
logger.warning('update computed fields took %d queries',
len(connection.queries) - queries_before2)
# Check if the license is valid.
# If the license is not valid, a CommandError will be thrown,
# and inventory update will be marked as invalid.
# with transaction.atomic() will roll back the changes.
license_fail = True
self.check_license()
# Check the per-org host limits
license_fail = False
self.check_org_host_limit()
except CommandError as e:
if license_fail:
self.mark_license_failure()
else:
self.mark_org_limits_failure()
raise e
try:
# Check the per-org host limits
self.check_org_host_limit()
except CommandError as e:
self.mark_org_limits_failure(save=True)
raise e
status, tb, exc = 'error', '', None
try:
if settings.SQL_DEBUG:
logger.warning('Inventory import completed for %s in %0.1fs',
self.inventory_source.name, time.time() - begin)
queries_before = len(connection.queries)
# Update inventory update for this command line invocation.
with ignore_inventory_computed_fields():
iu = self.inventory_update
if iu.status != 'running':
with transaction.atomic():
self.inventory_update.status = 'running'
self.inventory_update.save()
source = self.get_source_absolute_path(self.source)
data = AnsibleInventoryLoader(source=source, is_custom=self.is_custom,
venv_path=venv_path, verbosity=self.verbosity).load()
logger.debug('Finished loading from source: %s', source)
logger.info('Processing JSON output...')
inventory = MemInventory(
group_filter_re=self.group_filter_re, host_filter_re=self.host_filter_re)
inventory = dict_to_mem_data(data, inventory=inventory)
del data # forget dict from import, could be large
logger.info('Loaded %d groups, %d hosts', len(inventory.all_group.all_groups),
len(inventory.all_group.all_hosts))
if self.exclude_empty_groups:
inventory.delete_empty_groups()
self.all_group = inventory.all_group
if settings.DEBUG:
# depending on inventory source, this output can be
# *exceedingly* verbose - crawling a deeply nested
# inventory/group data structure and printing metadata about
# each host and its memberships
#
# it's easy for this scale of data to overwhelm pexpect,
# (and it's likely only useful for purposes of debugging the
# actual inventory import code), so only print it if we have to:
# https://github.com/ansible/ansible-tower/issues/7414#issuecomment-321615104
self.all_group.debug_tree()
with batch_role_ancestor_rebuilding():
# If using with transaction.atomic() with try ... catch,
# with transaction.atomic() must be inside the try section of the code as per Django docs
try:
# Ensure that this is managed as an atomic SQL transaction,
# and thus properly rolled back if there is an issue.
with transaction.atomic():
# Merge/overwrite inventory into database.
if settings.SQL_DEBUG:
logger.warning('loading into database...')
with ignore_inventory_computed_fields():
if getattr(settings, 'ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC', True):
self.load_into_database()
else:
with disable_activity_stream():
self.load_into_database()
if settings.SQL_DEBUG:
queries_before2 = len(connection.queries)
self.inventory.update_computed_fields()
if settings.SQL_DEBUG:
logger.warning('update computed fields took %d queries',
len(connection.queries) - queries_before2)
# Check if the license is valid.
# If the license is not valid, a CommandError will be thrown,
# and inventory update will be marked as invalid.
# with transaction.atomic() will roll back the changes.
license_fail = True
self.check_license()
# Check the per-org host limits
license_fail = False
self.check_org_host_limit()
except CommandError as e:
if license_fail:
self.mark_license_failure()
else:
self.mark_org_limits_failure()
raise e
if settings.SQL_DEBUG:
logger.warning('Inventory import completed for %s in %0.1fs',
self.inventory_source.name, time.time() - begin)
else:
logger.info('Inventory import completed for %s in %0.1fs',
self.inventory_source.name, time.time() - begin)
status = 'successful'
# If we're in debug mode, then log the queries and time
# used to do the operation.
if settings.SQL_DEBUG:
queries_this_import = connection.queries[queries_before:]
sqltime = sum(float(x['time']) for x in queries_this_import)
logger.warning('Inventory import required %d queries '
'taking %0.3fs', len(queries_this_import),
sqltime)
except Exception as e:
if isinstance(e, KeyboardInterrupt):
status = 'canceled'
exc = e
elif isinstance(e, CommandError):
exc = e
else:
logger.info('Inventory import completed for %s in %0.1fs',
self.inventory_source.name, time.time() - begin)
status = 'successful'
tb = traceback.format_exc()
exc = e
# If we're in debug mode, then log the queries and time
# used to do the operation.
if settings.SQL_DEBUG:
queries_this_import = connection.queries[queries_before:]
sqltime = sum(float(x['time']) for x in queries_this_import)
logger.warning('Inventory import required %d queries '
'taking %0.3fs', len(queries_this_import),
sqltime)
except Exception as e:
if isinstance(e, KeyboardInterrupt):
status = 'canceled'
exc = e
elif isinstance(e, CommandError):
exc = e
else:
tb = traceback.format_exc()
exc = e
if not self.invoked_from_dispatcher:
with ignore_inventory_computed_fields():
self.inventory_update = InventoryUpdate.objects.get(pk=self.inventory_update.pk)
self.inventory_update.result_traceback = tb
self.inventory_update.status = status
self.inventory_update.save(update_fields=['status', 'result_traceback'])
self.inventory_source.status = status
self.inventory_source.save(update_fields=['status'])
if not self.invoked_from_dispatcher:
with ignore_inventory_computed_fields():
self.inventory_update = InventoryUpdate.objects.get(pk=self.inventory_update.pk)
self.inventory_update.result_traceback = tb
self.inventory_update.status = status
self.inventory_update.save(update_fields=['status', 'result_traceback'])
self.inventory_source.status = status
self.inventory_source.save(update_fields=['status'])
if exc:
logger.error(str(exc))
if exc:
logger.error(str(exc))
if exc:
if isinstance(exc, CommandError):

View File

@@ -261,18 +261,20 @@ class InstanceGroup(HasPolicyEditsMixin, BaseModel, RelatedJobsMixin):
app_label = 'main'
def fit_task_to_most_remaining_capacity_instance(self, task):
@staticmethod
def fit_task_to_most_remaining_capacity_instance(task, instances):
instance_most_capacity = None
for i in self.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'):
for i in instances:
if i.remaining_capacity >= task.task_impact and \
(instance_most_capacity is None or
i.remaining_capacity > instance_most_capacity.remaining_capacity):
instance_most_capacity = i
return instance_most_capacity
def find_largest_idle_instance(self):
@staticmethod
def find_largest_idle_instance(instances):
largest_instance = None
for i in self.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'):
for i in instances:
if i.jobs_running == 0:
if largest_instance is None:
largest_instance = i

View File

@@ -393,7 +393,11 @@ class JobNotificationMixin(object):
'job': job_context,
'job_friendly_name': self.get_notification_friendly_name(),
'url': self.get_ui_url(),
'job_metadata': json.dumps(self.notification_data(), indent=4)
'job_metadata': json.dumps(
self.notification_data(),
ensure_ascii=False,
indent=4
)
}
def build_context(node, fields, allowed_fields):

View File

@@ -873,7 +873,13 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
# If status changed, update the parent instance.
if self.status != status_before:
self._update_parent_instance()
# Update parent outside of the transaction for Job w/ allow_simultaneous=True
# This dodges lock contention at the expense of the foreign key not being
# completely correct.
if getattr(self, 'allow_simultaneous', False):
connection.on_commit(self._update_parent_instance)
else:
self._update_parent_instance()
# Done.
return result

View File

@@ -776,6 +776,10 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
self.send_approval_notification('running')
return can_start
@property
def event_processing_finished(self):
return True
def send_approval_notification(self, approval_status):
from awx.main.tasks import send_notifications # avoid circular import
if self.workflow_job_template is None:

View File

@@ -7,6 +7,7 @@ import logging
import uuid
import json
import random
from types import SimpleNamespace
# Django
from django.db import transaction, connection
@@ -45,6 +46,15 @@ logger = logging.getLogger('awx.main.scheduler')
class TaskManager():
def __init__(self):
'''
Do NOT put database queries or other potentially expensive operations
in the task manager init. The task manager object is created every time a
job is created, transitions state, and every 30 seconds on each tower node.
More often then not, the object is destroyed quickly because the NOOP case is hit.
The NOOP case is short-circuit logic. If the task manager realizes that another instance
of the task manager is already running, then it short-circuits and decides not to run.
'''
self.graph = dict()
# start task limit indicates how many pending jobs can be started on this
# .schedule() run. Starting jobs is expensive, and there is code in place to reap
@@ -52,10 +62,30 @@ class TaskManager():
# 5 minutes to start pending jobs. If this limit is reached, pending jobs
# will no longer be started and will be started on the next task manager cycle.
self.start_task_limit = settings.START_TASK_LIMIT
def after_lock_init(self):
'''
Init AFTER we know this instance of the task manager will run because the lock is acquired.
'''
instances = Instance.objects.filter(capacity__gt=0, enabled=True)
self.real_instances = {i.hostname: i for i in instances}
instances_partial = [SimpleNamespace(obj=instance,
remaining_capacity=instance.remaining_capacity,
capacity=instance.capacity,
jobs_running=instance.jobs_running,
hostname=instance.hostname) for instance in instances]
instances_by_hostname = {i.hostname: i for i in instances_partial}
for rampart_group in InstanceGroup.objects.prefetch_related('instances'):
self.graph[rampart_group.name] = dict(graph=DependencyGraph(rampart_group.name),
capacity_total=rampart_group.capacity,
consumed_capacity=0)
consumed_capacity=0,
instances=[])
for instance in rampart_group.instances.filter(capacity__gt=0, enabled=True).order_by('hostname'):
if instance.hostname in instances_by_hostname:
self.graph[rampart_group.name]['instances'].append(instances_by_hostname[instance.hostname])
def is_job_blocked(self, task):
# TODO: I'm not happy with this, I think blocking behavior should be decided outside of the dependency graph
@@ -466,7 +496,6 @@ class TaskManager():
continue
preferred_instance_groups = task.preferred_instance_groups
found_acceptable_queue = False
idle_instance_that_fits = None
if isinstance(task, WorkflowJob):
if task.unified_job_template_id in running_workflow_templates:
if not task.allow_simultaneous:
@@ -483,24 +512,23 @@ class TaskManager():
found_acceptable_queue = True
break
if idle_instance_that_fits is None:
idle_instance_that_fits = rampart_group.find_largest_idle_instance()
remaining_capacity = self.get_remaining_capacity(rampart_group.name)
if not rampart_group.is_containerized and self.get_remaining_capacity(rampart_group.name) <= 0:
logger.debug("Skipping group {}, remaining_capacity {} <= 0".format(
rampart_group.name, remaining_capacity))
continue
execution_instance = rampart_group.fit_task_to_most_remaining_capacity_instance(task)
if execution_instance:
logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format(
task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity))
elif not execution_instance and idle_instance_that_fits:
execution_instance = InstanceGroup.fit_task_to_most_remaining_capacity_instance(task, self.graph[rampart_group.name]['instances']) or \
InstanceGroup.find_largest_idle_instance(self.graph[rampart_group.name]['instances'])
if execution_instance or rampart_group.is_containerized:
if not rampart_group.is_containerized:
execution_instance = idle_instance_that_fits
execution_instance.remaining_capacity = max(0, execution_instance.remaining_capacity - task.task_impact)
execution_instance.jobs_running += 1
logger.debug("Starting {} in group {} instance {} (remaining_capacity={})".format(
task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity))
if execution_instance or rampart_group.is_containerized:
execution_instance = self.real_instances[execution_instance.hostname]
self.graph[rampart_group.name]['graph'].add_job(task)
self.start_task(task, rampart_group, task.get_jobs_fail_chain(), execution_instance)
found_acceptable_queue = True
@@ -572,6 +600,9 @@ class TaskManager():
def _schedule(self):
finished_wfjs = []
all_sorted_tasks = self.get_tasks()
self.after_lock_init()
if len(all_sorted_tasks) > 0:
# TODO: Deal with
# latest_project_updates = self.get_latest_project_update_tasks(all_sorted_tasks)

View File

@@ -89,7 +89,7 @@ class TestApprovalNodes():
url = reverse('api:workflow_job_template_node_create_approval',
kwargs={'pk': approval_node.pk, 'version': 'v2'})
post(url, {'name': 'Test', 'description': 'Approval Node', 'timeout': 0},
user=admin_user, expect=200)
user=admin_user, expect=201)
approval_node = WorkflowJobTemplateNode.objects.get(pk=approval_node.pk)
assert isinstance(approval_node.unified_job_template, WorkflowApprovalTemplate)
@@ -108,9 +108,9 @@ class TestApprovalNodes():
assert {'name': ['This field may not be blank.']} == json.loads(r.content)
@pytest.mark.parametrize("is_admin, is_org_admin, status", [
[True, False, 200], # if they're a WFJT admin, they get a 200
[True, False, 201], # if they're a WFJT admin, they get a 201
[False, False, 403], # if they're not a WFJT *nor* org admin, they get a 403
[False, True, 200], # if they're an organization admin, they get a 200
[False, True, 201], # if they're an organization admin, they get a 201
])
def test_approval_node_creation_rbac(self, post, approval_node, alice, is_admin, is_org_admin, status):
url = reverse('api:workflow_job_template_node_create_approval',
@@ -165,7 +165,7 @@ class TestApprovalNodes():
url = reverse('api:workflow_job_template_node_create_approval',
kwargs={'pk': node.pk, 'version': 'v2'})
post(url, {'name': 'Approve Test', 'description': '', 'timeout': 0},
user=admin_user, expect=200)
user=admin_user, expect=201)
post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}),
user=admin_user, expect=201)
wf_job = WorkflowJob.objects.first()
@@ -195,7 +195,7 @@ class TestApprovalNodes():
url = reverse('api:workflow_job_template_node_create_approval',
kwargs={'pk': node.pk, 'version': 'v2'})
post(url, {'name': 'Deny Test', 'description': '', 'timeout': 0},
user=admin_user, expect=200)
user=admin_user, expect=201)
post(reverse('api:workflow_job_template_launch', kwargs={'pk': wfjt.pk}),
user=admin_user, expect=201)
wf_job = WorkflowJob.objects.first()

View File

@@ -123,6 +123,15 @@ class TestJobNotificationMixin(object):
context = job.context(job_serialization)
check_structure(TestJobNotificationMixin.CONTEXT_STRUCTURE, context)
@pytest.mark.django_db
def test_context_job_metadata_with_unicode(self):
job = Job.objects.create(name='批量安装项目')
job_serialization = UnifiedJobSerializer(job).to_representation(job)
context = job.context(job_serialization)
assert '批量安装项目' in context['job_metadata']
def test_context_stub(self):
"""The context stub is a fake context used to validate custom notification messages. Ensure that
this also has the expected structure. Furthermore, ensure that the stub context contains

View File

@@ -45,19 +45,14 @@ class TestInstanceGroup(object):
(T(100), Is([50, 0, 20, 99, 11, 1, 5, 99]), None, "The task don't a fit, you must a quit!"),
])
def test_fit_task_to_most_remaining_capacity_instance(self, task, instances, instance_fit_index, reason):
with mock.patch.object(InstanceGroup,
'instances',
Mock(spec_set=['filter'],
filter=lambda *args, **kargs: Mock(spec_set=['order_by'],
order_by=lambda x: instances))):
ig = InstanceGroup(id=10)
ig = InstanceGroup(id=10)
if instance_fit_index is None:
assert ig.fit_task_to_most_remaining_capacity_instance(task) is None, reason
else:
assert ig.fit_task_to_most_remaining_capacity_instance(task) == \
instances[instance_fit_index], reason
instance_picked = ig.fit_task_to_most_remaining_capacity_instance(task, instances)
if instance_fit_index is None:
assert instance_picked is None, reason
else:
assert instance_picked == instances[instance_fit_index], reason
@pytest.mark.parametrize('instances,instance_fit_index,reason', [
(Is([(0, 100)]), 0, "One idle instance, pick it"),
@@ -70,16 +65,12 @@ class TestInstanceGroup(object):
def filter_offline_instances(*args):
return filter(lambda i: i.capacity > 0, instances)
with mock.patch.object(InstanceGroup,
'instances',
Mock(spec_set=['filter'],
filter=lambda *args, **kargs: Mock(spec_set=['order_by'],
order_by=filter_offline_instances))):
ig = InstanceGroup(id=10)
ig = InstanceGroup(id=10)
instances_online_only = filter_offline_instances(instances)
if instance_fit_index is None:
assert ig.find_largest_idle_instance() is None, reason
else:
assert ig.find_largest_idle_instance() == \
instances[instance_fit_index], reason
if instance_fit_index is None:
assert ig.find_largest_idle_instance(instances_online_only) is None, reason
else:
assert ig.find_largest_idle_instance(instances_online_only) == \
instances[instance_fit_index], reason

View File

@@ -159,23 +159,29 @@
gather_facts: false
connection: local
name: Install content with ansible-galaxy command if necessary
vars:
yaml_exts:
- {ext: .yml}
- {ext: .yaml}
tasks:
- block:
- name: detect requirements.yml
- name: detect roles/requirements.(yml/yaml)
stat:
path: '{{project_path|quote}}/roles/requirements.yml'
path: "{{project_path|quote}}/roles/requirements{{ item.ext }}"
with_items: "{{ yaml_exts }}"
register: doesRequirementsExist
- name: fetch galaxy roles from requirements.yml
- name: fetch galaxy roles from requirements.(yml/yaml)
command: >
ansible-galaxy role install -r roles/requirements.yml
ansible-galaxy role install -r {{ item.stat.path }}
--roles-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_roles
{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
args:
chdir: "{{project_path|quote}}"
register: galaxy_result
when: doesRequirementsExist.stat.exists
with_items: "{{ doesRequirementsExist.results }}"
when: item.stat.exists
changed_when: "'was installed successfully' in galaxy_result.stdout"
environment:
ANSIBLE_FORCE_COLOR: false
@@ -186,20 +192,22 @@
- install_roles
- block:
- name: detect collections/requirements.yml
- name: detect collections/requirements.(yml/yaml)
stat:
path: '{{project_path|quote}}/collections/requirements.yml'
path: "{{project_path|quote}}/collections/requirements{{ item.ext }}"
with_items: "{{ yaml_exts }}"
register: doesCollectionRequirementsExist
- name: fetch galaxy collections from collections/requirements.yml
- name: fetch galaxy collections from collections/requirements.(yml/yaml)
command: >
ansible-galaxy collection install -r collections/requirements.yml
ansible-galaxy collection install -r {{ item.stat.path }}
--collections-path {{projects_root}}/.__awx_cache/{{local_path}}/stage/requirements_collections
{{ ' -' + 'v' * ansible_verbosity if ansible_verbosity else '' }}
args:
chdir: "{{project_path|quote}}"
register: galaxy_collection_result
when: doesCollectionRequirementsExist.stat.exists
with_items: "{{ doesCollectionRequirementsExist.results }}"
when: item.stat.exists
changed_when: "'Installing ' in galaxy_collection_result.stdout"
environment:
ANSIBLE_FORCE_COLOR: false

View File

@@ -515,6 +515,7 @@ register(
help_text=_('TACACS+ session timeout value in seconds, 0 disables timeout.'),
category=_('TACACS+'),
category_slug='tacacsplus',
unit=_('seconds'),
)
register(

View File

@@ -445,6 +445,7 @@ class LDAPGroupTypeField(fields.ChoiceField, DependsOnMixin):
default_error_messages = {
'type_error': _('Expected an instance of LDAPGroupType but got {input_type} instead.'),
'missing_parameters': _('Missing required parameters in {dependency}.')
}
def __init__(self, choices=None, **kwargs):
@@ -479,7 +480,10 @@ class LDAPGroupTypeField(fields.ChoiceField, DependsOnMixin):
if attr in params:
params_sanitized[attr] = params[attr]
return cls(**params_sanitized)
try:
return cls(**params_sanitized)
except TypeError:
self.fail('missing_parameters', dependency=list(self.depends_on)[0])
class LDAPGroupTypeParamsField(fields.DictField, DependsOnMixin):

View File

@@ -7,6 +7,7 @@ export default ['$rootScope', '$scope', '$location', 'ConfigService', 'lastPath'
$scope.ansible_version = config.ansible_version;
$scope.subscription = config.license_info.subscription_name;
$scope.speechBubble = createSpeechBubble($rootScope.BRAND_NAME, config.version);
$scope.currentYear = new Date().getFullYear();
$('#about-modal').modal('show');
});

View File

@@ -29,7 +29,7 @@
Ansible {{ ansible_version }}
</span> <br>
<span class="Copyright-text" translate>
Copyright &copy; 2019 Red Hat, Inc. <br>
Copyright &copy; {{ currentYear }} Red Hat, Inc. <br>
Visit <a href="http://www.ansible.com/" target="_blank">Ansible.com</a> for more information.<br>
</span>
</p>

View File

@@ -11,8 +11,8 @@
* Controller for handling third party supported login options.
*/
export default ['$window', '$scope', 'thirdPartySignOnService',
function ($window, $scope, thirdPartySignOnService) {
export default ['$window', '$scope', 'thirdPartySignOnService', '$cookies', 'Authorization',
function ($window, $scope, thirdPartySignOnService, $cookies, Authorization) {
thirdPartySignOnService(
{scope: $scope, url: "api/v2/auth/"}).then(function (data) {
@@ -29,8 +29,16 @@ export default ['$window', '$scope', 'thirdPartySignOnService',
});
$scope.goTo = function(link) {
// this is used because $location only lets you navigate inside
// the "/#/" path, and these are API urls.
$window.location.href = link;
// clear out any prior auth state that might exist (e.g: from other
// tabs, etc.) before redirecting to the auth service
Authorization.logout().then(() => {
angular.forEach($cookies.getAll(), (val, name) => {
$cookies.remove(name);
});
$window.location.reload();
// this is used because $location only lets you navigate inside
// the "/#/" path, and these are API urls.
$window.location.href = link;
});
};
}];

View File

@@ -122,7 +122,7 @@
selected-id="webhookCredential.modalSelectedId"
on-ready="handleWebhookCredentialModalReady"
on-item-select="handleWebhookCredentialModalItemSelect"
/>
></at-lookup-list>
<at-action-group col="12" pos="right">
<at-action-button
variant="tertiary"

View File

@@ -171,7 +171,7 @@ export default
selected-id="webhookCredential.modalSelectedId"
on-ready="handleWebhookCredentialModalReady"
on-item-select="handleWebhookCredentialModalItemSelect"
/>
></at-lookup-list>
<at-action-group col="12" pos="right">
<at-action-button
variant="tertiary"

View File

@@ -140,7 +140,7 @@ export default [
selected-id="webhookCredential.modalSelectedId"
on-ready="handleWebhookCredentialModalReady"
on-item-select="handleWebhookCredentialModalItemSelect"
/>
></at-lookup-list>
<at-action-group col="12" pos="right">
<at-action-button
variant="tertiary"

View File

@@ -179,7 +179,7 @@ export default [
selected-id="webhookCredential.modalSelectedId"
on-ready="handleWebhookCredentialModalReady"
on-item-select="handleWebhookCredentialModalItemSelect"
/>
></at-lookup-list>
<at-action-group col="12" pos="right">
<at-action-button
variant="tertiary"

View File

@@ -171,9 +171,9 @@
"integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU="
},
"angular": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/angular/-/angular-1.7.9.tgz",
"integrity": "sha512-5se7ZpcOtu0MBFlzGv5dsM1quQDoDeUTwZrWjGtTNA7O88cD8TEk5IEKCTDa3uECV9XnvKREVUr7du1ACiWGFQ=="
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/angular/-/angular-1.8.1.tgz",
"integrity": "sha512-eiasF4uFXsmKD8qYpkKEi9rKVxMv0nIDZXsYrwzSbPIbjmTV05bx+18VDbRmMx7p+gL84T9Qw2NCpVe8w1QKHQ=="
},
"angular-breadcrumb": {
"version": "git+https://git@github.com/ansible/angular-breadcrumb.git#6c2b1ad45ad5fbe7adf39af1ef3b294ca8e207a9",
@@ -2797,11 +2797,6 @@
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
"dev": true
},
"complex.js": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.0.4.tgz",
"integrity": "sha512-Syl95HpxUTS0QjwNxencZsKukgh1zdS9uXeXX2Us0pHaqBR6kiZZi0AkZ9VpZFwHJyVIUVzI4EumjWdXP3fy6w=="
},
"component-bind": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
@@ -3456,11 +3451,6 @@
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
"dev": true
},
"decimal.js": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-9.0.1.tgz",
"integrity": "sha512-2h0iKbJwnImBk4TGk7CG1xadoA0g3LDPlQhQzbZ221zvG0p2YVUedbKIPsOZXKZGx6YmZMJKYOalpCMxSdDqTQ=="
},
"decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
@@ -4137,11 +4127,6 @@
"integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=",
"dev": true
},
"escape-latex": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.1.0.tgz",
"integrity": "sha512-7k372jNDrL8uW7P/Sw8IkF+QcaeGoyjzrLx4pJj/CSIe02CvxL1wUJ+qMVVHsna/jNZ6PD6aCo7iEeRnXTzvdw=="
},
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -5493,11 +5478,6 @@
"integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=",
"dev": true
},
"fraction.js": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.0.4.tgz",
"integrity": "sha512-aK/oGatyYLTtXRHjfEsytX5fieeR5H4s8sLorzcT12taFS+dbMZejnvm9gRa8mZAPwci24ucjq9epDyaq5u8Iw=="
},
"fragment-cache": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz",
@@ -8070,11 +8050,6 @@
"resolved": "https://registry.npmjs.org/javascript-detect-element-resize/-/javascript-detect-element-resize-0.5.3.tgz",
"integrity": "sha1-GnHNUd/lZZB/KZAS/nOilBBAJd4="
},
"javascript-natural-sort": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
"integrity": "sha1-+eIwPUUH9tdDVac2ZNFED7Wg71k="
},
"jquery": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz",
@@ -9186,21 +9161,6 @@
"integrity": "sha1-izqsWIuKZuSXXjzepn97sylgH6w=",
"dev": true
},
"mathjs": {
"version": "3.20.2",
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-3.20.2.tgz",
"integrity": "sha512-3f6/+uf1cUtIz1rYFz775wekl/UEDSQ3mU6xdxW7qzpvvhc2v28i3UtLsGTRB+u8OqDWoSX6Dz8gehaGFs6tCA==",
"requires": {
"complex.js": "2.0.4",
"decimal.js": "9.0.1",
"escape-latex": "^1.0.0",
"fraction.js": "4.0.4",
"javascript-natural-sort": "0.7.1",
"seed-random": "2.2.0",
"tiny-emitter": "2.0.2",
"typed-function": "0.10.7"
}
},
"md5.js": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz",
@@ -12266,11 +12226,6 @@
"ajv": "^5.0.0"
}
},
"seed-random": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/seed-random/-/seed-random-2.2.0.tgz",
"integrity": "sha1-KpsZ4lCoFwmSMaW5mk2vgLf77VQ="
},
"select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@@ -13505,11 +13460,6 @@
"version": "github:ansible/timezone-js#6937de14ce0c193961538bb5b3b12b7ef62a358f",
"from": "github:ansible/timezone-js#0.4.14"
},
"tiny-emitter": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz",
"integrity": "sha512-2NM0auVBGft5tee/OxP4PI3d8WItkDM+fPnaRAVo6xTDI2knbz9eC5ArWGqtGlYqiH3RU5yMpdyTTO7MguC4ow=="
},
"titlecase": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/titlecase/-/titlecase-1.1.2.tgz",
@@ -13654,11 +13604,6 @@
"mime-types": "~2.1.18"
}
},
"typed-function": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-0.10.7.tgz",
"integrity": "sha512-3mlZ5AwRMbLvUKkc8a1TI4RUJUS2H27pmD5q0lHRObgsoWzhDAX01yg82kwSP1FUw922/4Y9ZliIEh0qJZcz+g=="
},
"typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",

View File

@@ -97,7 +97,7 @@
},
"dependencies": {
"@uirouter/angularjs": "1.0.18",
"angular": "^1.7.9",
"angular": "^1.8.1",
"angular-breadcrumb": "git+https://git@github.com/ansible/angular-breadcrumb#0.4.1",
"angular-codemirror": "git+https://git@github.com/ansible/angular-codemirror.git#v1.2.1",
"angular-cookies": "^1.7.9",
@@ -128,7 +128,6 @@
"legacy-loader": "0.0.2",
"lodash": "^4.17.15",
"lr-infinite-scroll": "git+https://git@github.com/lorenzofox3/lrInfiniteScroll",
"mathjs": "^3.15.0",
"moment": "^2.19.4",
"ng-toast": "git+https://git@github.com/ansible/ngToast.git#v2.2.1",
"nvd3": "^1.8.6",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11672,9 +11672,9 @@
}
},
"node-forge": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz",
"integrity": "sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.10.0.tgz",
"integrity": "sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==",
"dev": true
},
"node-int64": {
@@ -15287,12 +15287,12 @@
"dev": true
},
"selfsigned": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.7.tgz",
"integrity": "sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA==",
"version": "1.10.8",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.8.tgz",
"integrity": "sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w==",
"dev": true,
"requires": {
"node-forge": "0.9.0"
"node-forge": "^0.10.0"
}
},
"semver": {

View File

@@ -24,6 +24,7 @@ import Projects from './models/Projects';
import Roles from './models/Roles';
import Root from './models/Root';
import Schedules from './models/Schedules';
import Settings from './models/Settings';
import SystemJobs from './models/SystemJobs';
import Teams from './models/Teams';
import Tokens from './models/Tokens';
@@ -61,6 +62,7 @@ const ProjectsAPI = new Projects();
const RolesAPI = new Roles();
const RootAPI = new Root();
const SchedulesAPI = new Schedules();
const SettingsAPI = new Settings();
const SystemJobsAPI = new SystemJobs();
const TeamsAPI = new Teams();
const TokensAPI = new Tokens();
@@ -99,6 +101,7 @@ export {
RolesAPI,
RootAPI,
SchedulesAPI,
SettingsAPI,
SystemJobsAPI,
TeamsAPI,
TokensAPI,

View File

@@ -12,7 +12,9 @@ class Groups extends Base {
}
associateHost(id, hostId) {
return this.http.post(`${this.baseUrl}${id}/hosts/`, { id: hostId });
return this.http.post(`${this.baseUrl}${id}/hosts/`, {
id: hostId,
});
}
createHost(id, data) {
@@ -20,7 +22,9 @@ class Groups extends Base {
}
readAllHosts(id, params) {
return this.http.get(`${this.baseUrl}${id}/all_hosts/`, { params });
return this.http.get(`${this.baseUrl}${id}/all_hosts/`, {
params,
});
}
disassociateHost(id, host) {
@@ -29,6 +33,10 @@ class Groups extends Base {
disassociate: true,
});
}
readChildren(id, params) {
return this.http.get(`${this.baseUrl}${id}/children/`, params);
}
}
export default Groups;

View File

@@ -0,0 +1,26 @@
import Base from '../Base';
class Settings extends Base {
constructor(http) {
super(http);
this.baseUrl = '/api/v2/settings/';
}
readAllOptions() {
return this.http.options(`${this.baseUrl}all/`);
}
updateAll(data) {
return this.http.patch(`${this.baseUrl}all/`, data);
}
readCategory(category) {
return this.http.get(`${this.baseUrl}${category}/`);
}
readCategoryOptions(category) {
return this.http.options(`${this.baseUrl}${category}/`);
}
}
export default Settings;

View File

@@ -1,19 +1,25 @@
import React, { useState, Fragment, useCallback, useEffect } from 'react';
import React, { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import PropTypes from 'prop-types';
import useRequest, { useDismissableError } from '../../util/useRequest';
import { InventoriesAPI } from '../../api';
import AlertModal from '../AlertModal';
import { CredentialTypesAPI } from '../../api';
import ErrorDetail from '../ErrorDetail';
import AdHocCommandsWizard from './AdHocCommandsWizard';
import ContentLoading from '../ContentLoading';
import ContentError from '../ContentError';
function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
const [isWizardOpen, setIsWizardOpen] = useState(false);
function AdHocCommands({
onClose,
adHocItems,
itemId,
i18n,
moduleOptions,
credentialTypeId,
}) {
const history = useHistory();
const verbosityOptions = [
{ value: '0', key: '0', label: i18n._(t`0 (Normal)`) },
@@ -22,59 +28,26 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
{ value: '3', key: '3', label: i18n._(t`3 (Debug)`) },
{ value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) },
];
const {
error: fetchError,
request: fetchModuleOptions,
result: { moduleOptions, credentialTypeId },
} = useRequest(
useCallback(async () => {
const [choices, credId] = await Promise.all([
apiModule.readAdHocOptions(itemId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
const itemObject = (item, index) => {
return {
key: index,
value: item,
label: `${item}`,
isDisabled: false,
};
};
const options = choices.data.actions.GET.module_name.choices.map(
(choice, index) => itemObject(choice[0], index)
);
return {
moduleOptions: [itemObject('', -1), ...options],
credentialTypeId: credId.data.results[0].id,
};
}, [itemId, apiModule]),
{ moduleOptions: [] }
);
useEffect(() => {
fetchModuleOptions();
}, [fetchModuleOptions]);
const {
isloading: isLaunchLoading,
error: launchError,
error,
request: launchAdHocCommands,
} = useRequest(
useCallback(
async values => {
const { data } = await apiModule.launchAdHocCommands(itemId, values);
const { data } = await InventoriesAPI.launchAdHocCommands(
itemId,
values
);
history.push(`/jobs/command/${data.id}/output`);
},
[apiModule, itemId, history]
[itemId, history]
)
);
const { error, dismissError } = useDismissableError(
launchError || fetchError
);
const { dismissError } = useDismissableError(error);
const handleSubmit = async values => {
const { credential, ...remainingValues } = values;
@@ -85,14 +58,13 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
...remainingValues,
};
await launchAdHocCommands(manipulatedValues);
setIsWizardOpen(false);
};
if (isLaunchLoading) {
return <ContentLoading />;
}
if (error && isWizardOpen) {
if (error) {
return (
<AlertModal
isOpen={error}
@@ -100,43 +72,29 @@ function AdHocCommands({ children, apiModule, adHocItems, itemId, i18n }) {
title={i18n._(t`Error!`)}
onClose={() => {
dismissError();
setIsWizardOpen(false);
}}
>
{launchError ? (
<>
{i18n._(t`Failed to launch job.`)}
<ErrorDetail error={error} />
</>
) : (
<ContentError error={error} />
)}
<>
{i18n._(t`Failed to launch job.`)}
<ErrorDetail error={error} />
</>
</AlertModal>
);
}
return (
<Fragment>
{children({
openAdHocCommands: () => setIsWizardOpen(true),
})}
{isWizardOpen && (
<AdHocCommandsWizard
adHocItems={adHocItems}
moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId}
onCloseWizard={() => setIsWizardOpen(false)}
onLaunch={handleSubmit}
onDismissError={() => dismissError()}
/>
)}
</Fragment>
<AdHocCommandsWizard
adHocItems={adHocItems}
moduleOptions={moduleOptions}
verbosityOptions={verbosityOptions}
credentialTypeId={credentialTypeId}
onCloseWizard={onClose}
onLaunch={handleSubmit}
onDismissError={() => dismissError()}
/>
);
}
AdHocCommands.propTypes = {
children: PropTypes.func.isRequired,
adHocItems: PropTypes.arrayOf(PropTypes.object).isRequired,
itemId: PropTypes.number.isRequired,
};

View File

@@ -18,6 +18,10 @@ const credentials = [
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
];
const moduleOptions = [
['command', 'command'],
['shell', 'shell'],
];
const adHocItems = [
{
name: 'Inventory 1 Org 0',
@@ -25,10 +29,6 @@ const adHocItems = [
{ name: 'Inventory 2 Org 0' },
];
const children = ({ openAdHocCommands }) => (
<button type="submit" onClick={() => openAdHocCommands()} />
);
describe('<AdHocCommands />', () => {
let wrapper;
afterEach(() => {
@@ -40,111 +40,38 @@ describe('<AdHocCommands />', () => {
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
css="margin-right: 20px"
onClose={() => {}}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
);
});
expect(wrapper.find('AdHocCommands').length).toBe(1);
});
test('calls api on Mount', async () => {
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
);
});
expect(wrapper.find('AdHocCommands').length).toBe(1);
expect(InventoriesAPI.readAdHocOptions).toBeCalledWith(1);
expect(CredentialTypesAPI.read).toBeCalledWith({ namespace: 'ssh' });
});
test('should open the wizard', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['foo', 'foo'],
],
},
verbosity: { choices: [[1], [2]] },
},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] },
});
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
);
});
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update();
expect(wrapper.find('AdHocCommandsWizard').length).toBe(1);
});
test('should submit properly', async () => {
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: {
module_name: {
choices: [
['command', 'command'],
['foo', 'foo'],
],
},
verbosity: { choices: [[1], [2]] },
},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { results: [{ id: 1 }] },
});
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
CredentialsAPI.read.mockResolvedValue({
data: {
results: credentials,
count: 5,
},
});
InventoriesAPI.launchAdHocCommands.mockResolvedValue({ data: { id: 1 } });
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
css="margin-right: 20px"
onClose={() => {}}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
);
});
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update();
@@ -177,6 +104,7 @@ describe('<AdHocCommands />', () => {
);
await waitForElement(wrapper, 'ContentEmpty', el => el.length === 0);
// second step of wizard
await act(async () => {
wrapper
.find('input[aria-labelledby="check-action-item-4"]')
@@ -205,10 +133,6 @@ describe('<AdHocCommands />', () => {
module_name: 'command',
verbosity: 1,
});
wrapper.update();
expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
});
test('should throw error on submission properly', async () => {
@@ -251,16 +175,15 @@ describe('<AdHocCommands />', () => {
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
css="margin-right: 20px"
onClose={() => {}}
credentialTypeId={1}
>
{children}
</AdHocCommands>
itemId={1}
adHocItems={adHocItems}
moduleOptions={moduleOptions}
/>
);
});
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update();
@@ -312,36 +235,6 @@ describe('<AdHocCommands />', () => {
wrapper.find('Button[type="submit"]').prop('onClick')()
);
waitForElement(wrapper, 'ErrorDetail', el => el.length > 0);
expect(wrapper.find('AdHocCommandsWizard').length).toBe(0);
});
test('should open alert modal when error on fetching data', async () => {
InventoriesAPI.readAdHocOptions.mockRejectedValue(
new Error({
response: {
config: {
method: 'options',
url: '/api/v2/inventories/1/',
},
data: 'An error occurred',
status: 403,
},
})
);
await act(async () => {
wrapper = mountWithContexts(
<AdHocCommands
apiModule={InventoriesAPI}
adHocItems={adHocItems}
itemId={1}
credentialTypeId={1}
>
{children}
</AdHocCommands>
);
});
await act(async () => wrapper.find('button').prop('onClick')());
wrapper.update();
expect(wrapper.find('ErrorDetail').length).toBe(1);
await waitForElement(wrapper, 'ErrorDetail', el => el.length > 0);
});
});

View File

@@ -1,13 +1,26 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { ExclamationCircleIcon as PFExclamationCircleIcon } from '@patternfly/react-icons';
import { Tooltip } from '@patternfly/react-core';
import { withFormik, useFormikContext } from 'formik';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import Wizard from '../Wizard';
import AdHocCredentialStep from './AdHocCredentialStep';
import AdHocDetailsStep from './AdHocDetailsStep';
const AlertText = styled.div`
color: var(--pf-global--danger-color--200);
font-weight: var(--pf-global--FontWeight--bold);
`;
const ExclamationCircleIcon = styled(PFExclamationCircleIcon)`
margin-left: 10px;
color: var(--pf-global--danger-color--100);
`;
function AdHocCommandsWizard({
onLaunch,
i18n,
@@ -19,7 +32,7 @@ function AdHocCommandsWizard({
const [currentStepId, setCurrentStepId] = useState(1);
const [enableLaunch, setEnableLaunch] = useState(false);
const { values } = useFormikContext();
const { values, errors, touched } = useFormikContext();
const enabledNextOnDetailsStep = () => {
if (!values.module_name) {
@@ -36,11 +49,26 @@ function AdHocCommandsWizard({
}
return undefined; // makes the linter happy;
};
const hasDetailsStepError = errors.module_args && touched.module_args;
const steps = [
{
id: 1,
key: 1,
name: i18n._(t`Details`),
name: hasDetailsStepError ? (
<AlertText>
{i18n._(t`Details`)}
<Tooltip
position="right"
content={i18n._(t`This step contains errors`)}
trigger="click mouseenter focus"
>
<ExclamationCircleIcon />
</Tooltip>
</AlertText>
) : (
i18n._(t`Details`)
),
component: (
<AdHocDetailsStep
moduleOptions={moduleOptions}
@@ -60,7 +88,7 @@ function AdHocCommandsWizard({
onEnableLaunch={() => setEnableLaunch(true)}
/>
),
enableNext: enableLaunch,
enableNext: enableLaunch && Object.values(errors).length === 0,
nextButtonText: i18n._(t`Launch`),
canJumpTo: currentStepId >= 2,
},

View File

@@ -148,6 +148,20 @@ describe('<AdHocCommandsWizard/>', () => {
expect(onLaunch).toHaveBeenCalled();
});
test('should show error in navigation bar', async () => {
await waitForElement(wrapper, 'WizardNavItem', el => el.length > 0);
await act(async () => {
wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')(
{},
'command'
);
wrapper.find('input#module_args').simulate('change', {
target: { value: '', name: 'module_args' },
});
});
waitForElement(wrapper, 'ExclamationCircleIcon', el => el.length > 0);
});
test('expect credential step to throw error', async () => {
CredentialsAPI.read.mockRejectedValue(

View File

@@ -65,6 +65,7 @@ function AdHocCredentialStep({ i18n, credentialTypeId, onEnableLaunch }) {
<FormGroup
fieldId="credential"
label={i18n._(t`Machine Credential`)}
aria-label={i18n._(t`Machine Credential`)}
isRequired
validated={
!credentialMeta.touched || !credentialMeta.error ? 'default' : 'error'

View File

@@ -27,32 +27,43 @@ const TooltipWrapper = styled.div`
// in failing tests.
const brandName = BrandName;
function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
const [module_nameField, module_nameMeta, module_nameHelpers] = useField({
function AdHocDetailsStep({ i18n, verbosityOptions, moduleOptions }) {
const [moduleNameField, moduleNameMeta, moduleNameHelpers] = useField({
name: 'module_name',
validate: required(null, i18n),
});
const [variablesField] = useField('extra_vars');
const [diff_modeField, , diff_modeHelpers] = useField('diff_mode');
const [become_enabledField, , become_enabledHelpers] = useField(
const [diffModeField, , diffModeHelpers] = useField('diff_mode');
const [becomeEnabledField, , becomeEnabledHelpers] = useField(
'become_enabled'
);
const [verbosityField, verbosityMeta, verbosityHelpers] = useField({
name: 'verbosity',
validate: required(null, i18n),
});
const argumentsRequired =
moduleNameField.value === 'command' || moduleNameField.value === 'shell';
const [, argumentsMeta, argumentsHelpers] = useField({
name: 'module_args',
validate: argumentsRequired && required(null, i18n),
});
const isValid = !argumentsMeta.error || !argumentsMeta.touched;
return (
<Form>
<FormColumnLayout>
<FormFullWidthLayout>
<FormGroup
fieldId="module_name"
aria-label={i18n._(t`Module`)}
label={i18n._(t`Module`)}
isRequired
helperTextInvalid={module_nameMeta.error}
helperTextInvalid={moduleNameMeta.error}
validated={
!module_nameMeta.touched || !module_nameMeta.error
!moduleNameMeta.touched || !moduleNameMeta.error
? 'default'
: 'error'
}
@@ -65,33 +76,52 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
}
>
<AnsibleSelect
{...module_nameField}
isValid={!module_nameMeta.touched || !module_nameMeta.error}
{...moduleNameField}
placeHolder={i18n._(t`Select a module`)}
isValid={!moduleNameMeta.touched || !moduleNameMeta.error}
id="module_name"
data={moduleOptions || []}
data={[
{
value: '',
key: '',
label: i18n._(t`Choose a module`),
isDisabled: true,
},
...moduleOptions.map(value => ({
value: value[0],
label: value[0],
key: value[0],
})),
]}
onChange={(event, value) => {
module_nameHelpers.setValue(value);
if (value !== 'command' && value !== 'shell') {
argumentsHelpers.setTouched(false);
}
moduleNameHelpers.setValue(value);
}}
/>
</FormGroup>
<FormField
id="module_args"
name="module_args"
aria-label={i18n._(t`Arguments`)}
type="text"
label={i18n._(t`Arguments`)}
validate={required(null, i18n)}
validated={isValid ? 'default' : 'error'}
onBlur={() => argumentsHelpers.setTouched(true)}
placeholder={i18n._(t`Enter arguments`)}
isRequired={
module_nameField.value === 'command' ||
module_nameField.value === 'shell'
moduleNameField.value === 'command' ||
moduleNameField.value === 'shell'
}
tooltip={
module_nameField.value ? (
moduleNameField.value ? (
<>
{i18n._(
t`These arguments are used with the specified module. You can find information about ${module_nameField.value} by clicking `
t`These arguments are used with the specified module. You can find information about ${moduleNameField.value} by clicking `
)}
<a
href={`https://docs.ansible.com/ansible/latest/modules/${module_nameField.value}_module.html`}
href={`https://docs.ansible.com/ansible/latest/modules/${moduleNameField.value}_module.html`}
target="_blank"
rel="noopener noreferrer"
>
@@ -106,6 +136,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
/>
<FormGroup
fieldId="verbosity"
aria-label={i18n._(t`Verbosity`)}
label={i18n._(t`Verbosity`)}
isRequired
validated={
@@ -137,6 +168,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
name="limit"
type="text"
label={i18n._(t`Limit`)}
aria-label={i18n._(t`Limit`)}
tooltip={
<span>
{i18n._(
@@ -147,7 +179,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
target="_blank"
rel="noopener noreferrer"
>
{i18n._(`here`)}
{i18n._(t`here`)}
</a>
</span>
}
@@ -158,6 +190,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
type="number"
min="0"
label={i18n._(t`Forks`)}
aria-label={i18n._(t`Forks`)}
tooltip={
<span>
{i18n._(
@@ -176,6 +209,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
<FormColumnLayout>
<FormGroup
label={i18n._(t`Show changes`)}
aria-label={i18n._(t`Show changes`)}
labelIcon={
<FieldTooltip
content={i18n._(
@@ -189,9 +223,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
id="diff_mode"
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}
isChecked={diff_modeField.value}
isChecked={diffModeField.value}
onChange={() => {
diff_modeHelpers.setValue(!diff_modeField.value);
diffModeHelpers.setValue(!diffModeField.value);
}}
aria-label={i18n._(t`toggle changes`)}
/>
@@ -222,9 +256,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
</span>
}
id="become_enabled"
isChecked={become_enabledField.value}
isChecked={becomeEnabledField.value}
onChange={checked => {
become_enabledHelpers.setValue(checked);
becomeEnabledHelpers.setValue(checked);
}}
/>
</FormCheckboxLayout>
@@ -273,6 +307,7 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
</TooltipWrapper>
}
label={i18n._(t`Extra variables`)}
aria-label={i18n._(t`Extra variables`)}
/>
</FormFullWidthLayout>
</FormColumnLayout>
@@ -280,9 +315,9 @@ function CredentialStep({ i18n, verbosityOptions, moduleOptions }) {
);
}
CredentialStep.propTypes = {
AdHocDetailsStep.propTypes = {
moduleOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
verbosityOptions: PropTypes.arrayOf(PropTypes.object).isRequired,
};
export default withI18n()(CredentialStep);
export default withI18n()(AdHocDetailsStep);

View File

@@ -12,9 +12,8 @@ const verbosityOptions = [
{ key: 1, value: 1, label: '1', isDisabled: false },
];
const moduleOptions = [
{ key: -1, value: '', label: '', isDisabled: false },
{ key: 0, value: 'command', label: 'command', isDisabled: false },
{ key: 1, value: 'shell', label: 'shell', isDisabled: false },
['command', 'command'],
['shell', 'shell'],
];
const onLimitChange = jest.fn();
const initialValues = {

View File

@@ -48,7 +48,12 @@ function AddDropDownButton({ dropdownItems, i18n }) {
isPlain
isOpen={isOpen}
position={DropdownPosition.right}
toggle={<ToolbarAddButton onClick={() => setIsOpen(!isOpen)} />}
toggle={
<ToolbarAddButton
showToggleIndicator
onClick={() => setIsOpen(!isOpen)}
/>
}
dropdownItems={dropdownItems.map(item => (
<Link
className="pf-c-dropdown__menu-item"

View File

@@ -1,9 +1,10 @@
import 'styled-components/macro';
import React, { useState, useEffect } from 'react';
import { node, number, oneOfType, shape, string } from 'prop-types';
import { node, number, oneOfType, shape, string, arrayOf } from 'prop-types';
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '../DetailList';
import MultiButtonToggle from '../MultiButtonToggle';
import DetailPopover from '../DetailPopover';
import {
yamlToJson,
jsonToYaml,
@@ -27,7 +28,7 @@ function getValueAsMode(value, mode) {
return mode === YAML_MODE ? jsonToYaml(value) : yamlToJson(value);
}
function VariablesDetail({ value, label, rows, fullHeight }) {
function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
const [mode, setMode] = useState(
isJsonObject(value) || isJsonString(value) ? JSON_MODE : YAML_MODE
);
@@ -46,9 +47,14 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [value]);
const labelCy = dataCy ? `${dataCy}-label` : null;
const valueCy = dataCy ? `${dataCy}-value` : null;
return (
<>
<DetailName
data-cy={labelCy}
id={dataCy}
component={TextListItemVariants.dt}
fullWidth
css="grid-column: 1 / -1"
@@ -62,6 +68,9 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
>
{label}
</span>
{helpText && (
<DetailPopover header={label} content={helpText} id={dataCy} />
)}
</div>
</SplitItem>
<SplitItem>
@@ -84,6 +93,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
</Split>
</DetailName>
<DetailValue
data-cy={valueCy}
component={TextListItemVariants.dd}
fullWidth
css="grid-column: 1 / -1; margin-top: -20px"
@@ -109,7 +119,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
);
}
VariablesDetail.propTypes = {
value: oneOfType([shape({}), string]).isRequired,
value: oneOfType([shape({}), arrayOf(string), string]).isRequired,
label: node.isRequired,
rows: number,
};

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { node, bool } from 'prop-types';
import { node, bool, string } from 'prop-types';
import { TextListItem, TextListItemVariants } from '@patternfly/react-core';
import styled from 'styled-components';
import DetailPopover from '../DetailPopover';
const DetailName = styled(({ fullWidth, ...props }) => (
<TextListItem {...props} />
@@ -14,9 +15,11 @@ const DetailName = styled(({ fullWidth, ...props }) => (
`}
`;
const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
<TextListItem {...props} />
))`
const DetailValue = styled(
({ fullWidth, isEncrypted, isNotConfigured, ...props }) => (
<TextListItem {...props} />
)
)`
word-break: break-all;
${props =>
props.fullWidth &&
@@ -24,9 +27,8 @@ const DetailValue = styled(({ fullWidth, isEncrypted, ...props }) => (
grid-column: 2 / -1;
`}
${props =>
props.isEncrypted &&
(props.isEncrypted || props.isNotConfigured) &&
`
text-transform: uppercase
color: var(--pf-global--Color--400);
`}
`;
@@ -38,7 +40,9 @@ const Detail = ({
className,
dataCy,
alwaysVisible,
helpText,
isEncrypted,
isNotConfigured,
}) => {
if (!value && typeof value !== 'number' && !alwaysVisible) {
return null;
@@ -54,8 +58,12 @@ const Detail = ({
component={TextListItemVariants.dt}
fullWidth={fullWidth}
data-cy={labelCy}
id={dataCy}
>
{label}
{helpText && (
<DetailPopover header={label} content={helpText} id={dataCy} />
)}
</DetailName>
<DetailValue
className={className}
@@ -63,6 +71,7 @@ const Detail = ({
fullWidth={fullWidth}
data-cy={valueCy}
isEncrypted={isEncrypted}
isNotConfigured={isNotConfigured}
>
{value}
</DetailValue>
@@ -74,11 +83,13 @@ Detail.propTypes = {
value: node,
fullWidth: bool,
alwaysVisible: bool,
helpText: string,
};
Detail.defaultProps = {
value: null,
fullWidth: false,
alwaysVisible: false,
helpText: null,
};
export default Detail;

View File

@@ -0,0 +1,51 @@
import React, { useState } from 'react';
import { node, string } from 'prop-types';
import { Button as _Button, Popover } from '@patternfly/react-core';
import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
const Button = styled(_Button)`
--pf-c-button--PaddingTop: 0;
--pf-c-button--PaddingBottom: 0;
`;
function DetailPopover({ header, content, id }) {
const [showPopover, setShowPopover] = useState(false);
if (!content) {
return null;
}
return (
<Popover
bodyContent={content}
headerContent={header}
hideOnOutsideClick
id={id}
isVisible={showPopover}
shouldClose={() => setShowPopover(false)}
>
<Button
onClick={() => setShowPopover(!showPopover)}
variant="plain"
aria-haspopup="true"
aria-expanded={showPopover}
>
<OutlinedQuestionCircleIcon
onClick={() => setShowPopover(!showPopover)}
/>
</Button>
</Popover>
);
}
DetailPopover.propTypes = {
content: node,
header: node,
id: string,
};
DetailPopover.defaultProps = {
content: null,
header: null,
id: 'detail-popover',
};
export default DetailPopover;

View File

@@ -0,0 +1 @@
export { default } from './DetailPopover';

View File

@@ -5,37 +5,57 @@ import { t } from '@lingui/macro';
import { ActionGroup, Button } from '@patternfly/react-core';
import { FormFullWidthLayout } from '../FormLayout';
const FormActionGroup = ({ onSubmit, submitDisabled, onCancel, i18n }) => (
<FormFullWidthLayout>
<ActionGroup>
<Button
aria-label={i18n._(t`Save`)}
variant="primary"
type="button"
onClick={onSubmit}
isDisabled={submitDisabled}
>
{i18n._(t`Save`)}
</Button>
<Button
aria-label={i18n._(t`Cancel`)}
variant="secondary"
type="button"
onClick={onCancel}
>
{i18n._(t`Cancel`)}
</Button>
</ActionGroup>
</FormFullWidthLayout>
);
const FormActionGroup = ({
onCancel,
onRevert,
onSubmit,
submitDisabled,
i18n,
}) => {
return (
<FormFullWidthLayout>
<ActionGroup>
<Button
aria-label={i18n._(t`Save`)}
variant="primary"
type="button"
onClick={onSubmit}
isDisabled={submitDisabled}
>
{i18n._(t`Save`)}
</Button>
{onRevert && (
<Button
aria-label={i18n._(t`Revert`)}
variant="secondary"
type="button"
onClick={onRevert}
>
{i18n._(t`Revert`)}
</Button>
)}
<Button
aria-label={i18n._(t`Cancel`)}
variant="secondary"
type="button"
onClick={onCancel}
>
{i18n._(t`Cancel`)}
</Button>
</ActionGroup>
</FormFullWidthLayout>
);
};
FormActionGroup.propTypes = {
onCancel: PropTypes.func.isRequired,
onRevert: PropTypes.func,
onSubmit: PropTypes.func.isRequired,
submitDisabled: PropTypes.bool,
};
FormActionGroup.defaultProps = {
onRevert: null,
submitDisabled: false,
};

View File

@@ -2,32 +2,27 @@ import React from 'react';
import { Wizard } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Formik } from 'formik';
import { Formik, useFormikContext } from 'formik';
import ContentError from '../ContentError';
import ContentLoading from '../ContentLoading';
import { useDismissableError } from '../../util/useRequest';
import mergeExtraVars from './mergeExtraVars';
import useSteps from './useSteps';
import useLaunchSteps from './useLaunchSteps';
import AlertModal from '../AlertModal';
import getSurveyValues from './getSurveyValues';
function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
function PromptModalForm({ onSubmit, onCancel, i18n, config, resource }) {
const { values, setTouched, validateForm } = useFormikContext();
const {
steps,
initialValues,
isReady,
validate,
visitStep,
visitAllSteps,
contentError,
} = useSteps(config, resource, i18n);
} = useLaunchSteps(config, resource, i18n);
if (contentError) {
return <ContentError error={contentError} />;
}
if (!isReady) {
return <ContentLoading />;
}
const submit = values => {
const handleSave = () => {
const postValues = {};
const setValue = (key, value) => {
if (typeof value !== 'undefined' && value !== null) {
@@ -49,39 +44,89 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) {
: resource.extra_vars;
setValue('extra_vars', mergeExtraVars(extraVars, surveyValues));
setValue('scm_branch', values.scm_branch);
onLaunch(postValues);
onSubmit(postValues);
};
const { error, dismissError } = useDismissableError(contentError);
if (error) {
return (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => {
dismissError();
}}
>
<ContentError error={error} />
</AlertModal>
);
}
return (
<Formik initialValues={initialValues} onSubmit={submit} validate={validate}>
{({ validateForm, setTouched, handleSubmit }) => (
<Wizard
isOpen
onClose={onCancel}
onSave={handleSubmit}
onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
} else {
visitStep(prevStep.prevId);
}
await validateForm();
}}
onGoToStep={async (newStep, prevStep) => {
if (newStep.id === 'preview') {
visitAllSteps(setTouched);
} else {
visitStep(prevStep.prevId);
}
await validateForm();
}}
title={i18n._(t`Prompts`)}
steps={steps}
backButtonText={i18n._(t`Back`)}
cancelButtonText={i18n._(t`Cancel`)}
nextButtonText={i18n._(t`Next`)}
/>
)}
<Wizard
isOpen
onClose={onCancel}
onSave={handleSave}
onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
} else {
visitStep(prevStep.prevId);
}
await validateForm();
}}
onGoToStep={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') {
visitAllSteps(setTouched);
} else {
visitStep(prevStep.prevId);
}
await validateForm();
}}
title={i18n._(t`Prompts`)}
steps={
isReady
? steps
: [
{
name: i18n._(t`Content Loading`),
component: <ContentLoading />,
},
]
}
backButtonText={i18n._(t`Back`)}
cancelButtonText={i18n._(t`Cancel`)}
nextButtonText={i18n._(t`Next`)}
/>
);
}
function LaunchPrompt({ config, resource = {}, onLaunch, onCancel, i18n }) {
return (
<Formik
initialValues={{
verbosity: resource.verbosity || 0,
inventory: resource.summary_fields?.inventory || null,
credentials: resource.summary_fields?.credentials || null,
diff_mode: resource.diff_mode || false,
extra_vars: resource.extra_vars || '---',
job_type: resource.job_type || '',
job_tags: resource.job_tags || '',
skip_tags: resource.skip_tags || '',
scm_branch: resource.scm_branch || '',
limit: resource.limit || '',
}}
onSubmit={values => onLaunch(values)}
>
<PromptModalForm
onSubmit={values => onLaunch(values)}
onCancel={onCancel}
i18n={i18n}
config={config}
resource={resource}
/>
</Formik>
);
}

View File

@@ -95,7 +95,7 @@ describe('LaunchPrompt', () => {
expect(steps).toHaveLength(5);
expect(steps[0].name.props.children).toEqual('Inventory');
expect(steps[1].name).toEqual('Credentials');
expect(steps[2].name.props.children).toEqual('Other Prompts');
expect(steps[2].name).toEqual('Other Prompts');
expect(steps[3].name.props.children).toEqual('Survey');
expect(steps[4].name).toEqual('Preview');
});
@@ -167,7 +167,7 @@ describe('LaunchPrompt', () => {
const steps = wizard.prop('steps');
expect(steps).toHaveLength(2);
expect(steps[0].name.props.children).toEqual('Other Prompts');
expect(steps[0].name).toEqual('Other Prompts');
expect(isElementOfType(steps[0].component, OtherPromptsStep)).toEqual(true);
expect(isElementOfType(steps[1].component, PreviewStep)).toEqual(true);
});

View File

@@ -1,7 +1,10 @@
export default function getSurveyValues(values) {
const surveyValues = {};
Object.keys(values).forEach(key => {
if (key.startsWith('survey_')) {
if (key.startsWith('survey_') && values[key] !== []) {
if (Array.isArray(values[key]) && values[key].length === 0) {
return;
}
surveyValues[key.substr(7)] = values[key];
}
});

View File

@@ -51,6 +51,7 @@ function OtherPromptsStep({ config, i18n }) {
id="prompt-job-tags"
name="job_tags"
label={i18n._(t`Job Tags`)}
aria-label={i18n._(t`Job Tags`)}
tooltip={i18n._(t`Tags are useful when you have a large
playbook, and you want to run a specific part of a play or task.
Use commas to separate multiple tags. Refer to Ansible Tower
@@ -62,6 +63,7 @@ function OtherPromptsStep({ config, i18n }) {
id="prompt-skip-tags"
name="skip_tags"
label={i18n._(t`Skip Tags`)}
aria-label={i18n._(t`Skip Tags`)}
tooltip={i18n._(t`Skip tags are useful when you have a large
playbook, and you want to skip specific parts of a play or task.
Use commas to separate multiple tags. Refer to Ansible Tower
@@ -108,6 +110,7 @@ function JobTypeField({ i18n }) {
and report problems without executing the playbook.`)}
/>
}
isRequired
validated={isValid ? 'default' : 'error'}
>
<AnsibleSelect
@@ -129,6 +132,7 @@ function VerbosityField({ i18n }) {
{ value: '3', key: '3', label: i18n._(t`3 (Debug)`) },
{ value: '4', key: '4', label: i18n._(t`4 (Connection Debug)`) },
];
const isValid = !(meta.touched && meta.error);
return (
@@ -171,6 +175,7 @@ function ShowChangesToggle({ i18n }) {
</label>
</FieldHeader>
<Switch
aria-label={field.value ? i18n._(t`On`) : i18n._(t`Off`)}
id="prompt-show-changes"
label={i18n._(t`On`)}
labelOff={i18n._(t`Off`)}

View File

@@ -48,7 +48,7 @@ function PreviewStep({ resource, config, survey, formErrors, i18n }) {
return (
<Fragment>
{formErrors.length > 0 && (
{formErrors && (
<ErrorMessageWrapper>
{i18n._(t`Some of the previous step(s) have errors`)}
<Tooltip

View File

@@ -104,4 +104,31 @@ describe('PreviewStep', () => {
extra_vars: 'one: 1',
});
});
test('should remove survey with empty array value', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{ extra_vars: 'one: 1' }}
values={{ extra_vars: 'one: 1', survey_foo: [] }}
>
<PreviewStep
resource={resource}
config={{
ask_variables_on_launch: true,
}}
formErrors={formErrors}
/>
</Formik>
);
});
const detail = wrapper.find('PromptDetail');
expect(detail).toHaveLength(1);
expect(detail.prop('resource')).toEqual(resource);
expect(detail.prop('overrides')).toEqual({
extra_vars: 'one: 1',
});
});
});

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import {
Form,
@@ -114,15 +115,22 @@ function MultipleChoiceField({ question }) {
);
}
function MultiSelectField({ question }) {
function MultiSelectField({ question, i18n }) {
const [isOpen, setIsOpen] = useState(false);
const [field, meta, helpers] = useField(`survey_${question.variable}`);
const [field, meta, helpers] = useField({
name: `survey_${question.variable}`,
validate: question.isrequired ? required(null, i18n) : null,
});
const id = `survey-question-${question.variable}`;
const isValid = !(meta.touched && meta.error);
const hasActualValue = !question.required || meta.value.length > 0;
const isValid = !meta.touched || (!meta.error && hasActualValue);
return (
<FormGroup
fieldId={id}
helperTextInvalid={meta.error}
helperTextInvalid={
meta.error || i18n._(t`Must select a value for this field.`)
}
isRequired={question.required}
validated={isValid ? 'default' : 'error'}
label={question.question_name}
@@ -133,14 +141,19 @@ function MultiSelectField({ question }) {
id={id}
onToggle={setIsOpen}
onSelect={(event, option) => {
if (field.value.includes(option)) {
if (field?.value?.includes(option)) {
helpers.setValue(field.value.filter(o => o !== option));
} else {
helpers.setValue(field.value.concat(option));
}
helpers.setTouched(true);
}}
isOpen={isOpen}
selections={field.value}
onClear={() => {
helpers.setTouched(true);
helpers.setValue([]);
}}
>
{question.choices.split('\n').map(opt => (
<SelectOption key={opt} value={opt} />

View File

@@ -4,20 +4,9 @@ import CredentialsStep from './CredentialsStep';
const STEP_ID = 'credentials';
export default function useCredentialsStep(
config,
resource,
visitedSteps,
i18n
) {
const validate = () => {
return {};
};
export default function useCredentialsStep(config, i18n) {
return {
step: getStep(config, i18n),
initialValues: getInitialValues(config, resource),
validate,
isReady: true,
contentError: null,
formError: null,
@@ -39,12 +28,3 @@ function getStep(config, i18n) {
component: <CredentialsStep i18n={i18n} />,
};
}
function getInitialValues(config, resource) {
if (!config.ask_credential_on_launch) {
return {};
}
return {
credentials: resource?.summary_fields?.credentials || [],
};
}

View File

@@ -1,38 +1,19 @@
import React, { useState } from 'react';
import React from 'react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import InventoryStep from './InventoryStep';
import StepName from './StepName';
const STEP_ID = 'inventory';
export default function useInventoryStep(config, resource, visitedSteps, i18n) {
const [stepErrors, setStepErrors] = useState({});
const validate = values => {
if (
!config.ask_inventory_on_launch ||
(['workflow_job', 'workflow_job_template'].includes(resource.type) &&
!resource.inventory)
) {
return {};
}
const errors = {};
if (!values.inventory) {
errors.inventory = i18n._(t`An inventory must be selected`);
}
setStepErrors(errors);
return errors;
};
const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
export default function useInventoryStep(config, visitedSteps, i18n) {
const [, meta] = useField('inventory');
return {
step: getStep(config, hasErrors, i18n),
initialValues: getInitialValues(config, resource),
validate,
step: getStep(config, meta, i18n, visitedSteps),
isReady: true,
contentError: null,
formError: stepErrors,
formError: !meta.value,
setTouched: setFieldsTouched => {
setFieldsTouched({
inventory: true,
@@ -40,23 +21,24 @@ export default function useInventoryStep(config, resource, visitedSteps, i18n) {
},
};
}
function getStep(config, hasErrors, i18n) {
function getStep(config, meta, i18n, visitedSteps) {
if (!config.ask_inventory_on_launch) {
return null;
}
return {
id: STEP_ID,
name: <StepName hasErrors={hasErrors}>{i18n._(t`Inventory`)}</StepName>,
key: 3,
name: (
<StepName
hasErrors={
Object.keys(visitedSteps).includes(STEP_ID) &&
(!meta.value || meta.error)
}
>
{i18n._(t`Inventory`)}
</StepName>
),
component: <InventoryStep i18n={i18n} />,
};
}
function getInitialValues(config, resource) {
if (!config.ask_inventory_on_launch) {
return {};
}
return {
inventory: resource?.summary_fields?.inventory || null,
enableNext: true,
};
}

View File

@@ -1,31 +1,15 @@
import React, { useState } from 'react';
import React from 'react';
import { t } from '@lingui/macro';
import OtherPromptsStep from './OtherPromptsStep';
import StepName from './StepName';
const STEP_ID = 'other';
export default function useOtherPrompt(config, resource, visitedSteps, i18n) {
const [stepErrors, setStepErrors] = useState({});
const validate = values => {
const errors = {};
if (config.ask_job_type_on_launch && !values.job_type) {
errors.job_type = i18n._(t`This field must not be blank`);
}
setStepErrors(errors);
return errors;
};
const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
export default function useOtherPrompt(config, i18n) {
return {
step: getStep(config, hasErrors, i18n),
initialValues: getInitialValues(config, resource),
validate,
step: getStep(config, i18n),
isReady: true,
contentError: null,
formError: stepErrors,
formError: null,
setTouched: setFieldsTouched => {
setFieldsTouched({
job_type: true,
@@ -40,13 +24,13 @@ export default function useOtherPrompt(config, resource, visitedSteps, i18n) {
};
}
function getStep(config, hasErrors, i18n) {
function getStep(config, i18n) {
if (!shouldShowPrompt(config)) {
return null;
}
return {
id: STEP_ID,
name: <StepName hasErrors={hasErrors}>{i18n._(t`Other Prompts`)}</StepName>,
name: i18n._(t`Other Prompts`),
component: <OtherPromptsStep config={config} i18n={i18n} />,
};
}
@@ -63,32 +47,3 @@ function shouldShowPrompt(config) {
config.ask_diff_mode_on_launch
);
}
function getInitialValues(config, resource) {
const initialValues = {};
if (config.ask_job_type_on_launch) {
initialValues.job_type = resource.job_type || '';
}
if (config.ask_limit_on_launch) {
initialValues.limit = resource.limit || '';
}
if (config.ask_verbosity_on_launch) {
initialValues.verbosity = resource.verbosity || 0;
}
if (config.ask_tags_on_launch) {
initialValues.job_tags = resource.job_tags || '';
}
if (config.ask_skip_tags_on_launch) {
initialValues.skip_tags = resource.skip_tags || '';
}
if (config.ask_variables_on_launch) {
initialValues.extra_vars = resource.extra_vars || '---';
}
if (config.ask_scm_branch_on_launch) {
initialValues.scm_branch = resource.scm_branch || '';
}
if (config.ask_diff_mode_on_launch) {
initialValues.diff_mode = resource.diff_mode || false;
}
return initialValues;
}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useFormikContext } from 'formik';
import { t } from '@lingui/macro';
import PreviewStep from './PreviewStep';
@@ -8,9 +9,29 @@ export default function usePreviewStep(
config,
resource,
survey,
formErrors,
hasErrors,
i18n
) {
const { values: formikValues, errors } = useFormikContext();
const formErrorsContent = [];
if (config.ask_inventory_on_launch && !formikValues.inventory) {
formErrorsContent.push({
inventory: true,
});
}
const hasSurveyError = Object.keys(errors).find(e => e.includes('survey'));
if (
config.survey_enabled &&
(config.variables_needed_to_start ||
config.variables_needed_to_start.length === 0) &&
hasSurveyError
) {
formErrorsContent.push({
survey: true,
});
}
return {
step: {
id: STEP_ID,
@@ -20,14 +41,13 @@ export default function usePreviewStep(
config={config}
resource={resource}
survey={survey}
formErrors={formErrors}
formErrors={hasErrors}
/>
),
enableNext: Object.keys(formErrors).length === 0,
enableNext: !hasErrors,
nextButtonText: i18n._(t`Launch`),
},
initialValues: {},
validate: () => ({}),
isReady: true,
error: null,
setTouched: () => {},

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useEffect, useCallback } from 'react';
import { t } from '@lingui/macro';
import { useFormikContext } from 'formik';
import useRequest from '../../../util/useRequest';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../../api';
import SurveyStep from './SurveyStep';
@@ -7,27 +8,27 @@ import StepName from './StepName';
const STEP_ID = 'survey';
export default function useSurveyStep(config, resource, visitedSteps, i18n) {
const [stepErrors, setStepErrors] = useState({});
export default function useSurveyStep(config, visitedSteps, i18n) {
const { values } = useFormikContext();
const { result: survey, request: fetchSurvey, isLoading, error } = useRequest(
useCallback(async () => {
if (!config.survey_enabled) {
return {};
}
const { data } =
resource.type === 'workflow_job_template'
? await WorkflowJobTemplatesAPI.readSurvey(resource.id)
: await JobTemplatesAPI.readSurvey(resource.id);
const { data } = config?.workflow_job_template_data
? await WorkflowJobTemplatesAPI.readSurvey(
config?.workflow_job_template_data?.id
)
: await JobTemplatesAPI.readSurvey(config?.job_template_data?.id);
return data;
}, [config.survey_enabled, resource])
}, [config])
);
useEffect(() => {
fetchSurvey();
}, [fetchSurvey]);
const validate = values => {
const validate = () => {
if (!config.survey_enabled || !survey || !survey.spec) {
return {};
}
@@ -42,20 +43,16 @@ export default function useSurveyStep(config, resource, visitedSteps, i18n) {
errors[`survey_${question.variable}`] = errMessage;
}
});
setStepErrors(errors);
return errors;
};
const hasErrors = visitedSteps[STEP_ID] && Object.keys(stepErrors).length > 0;
const formError = Object.keys(validate()).length > 0;
return {
step: getStep(config, survey, hasErrors, i18n),
step: getStep(config, survey, formError, i18n, visitedSteps),
formError,
initialValues: getInitialValues(config, survey),
validate,
survey,
isReady: !isLoading && !!survey,
contentError: error,
formError: stepErrors,
setTouched: setFieldsTouched => {
if (!survey || !survey.spec) {
return;
@@ -87,34 +84,49 @@ function validateField(question, value, i18n) {
);
}
}
if (question.required && !value && value !== 0) {
if (
question.required &&
((!value && value !== 0) || (Array.isArray(value) && value.length === 0))
) {
return i18n._(t`This field must not be blank`);
}
return null;
}
function getStep(config, survey, hasErrors, i18n) {
function getStep(config, survey, hasErrors, i18n, visitedSteps) {
if (!config.survey_enabled) {
return null;
}
return {
id: STEP_ID,
name: <StepName hasErrors={hasErrors}>{i18n._(t`Survey`)}</StepName>,
key: 6,
name: (
<StepName
hasErrors={Object.keys(visitedSteps).includes(STEP_ID) && hasErrors}
>
{i18n._(t`Survey`)}
</StepName>
),
component: <SurveyStep survey={survey} i18n={i18n} />,
enableNext: true,
};
}
function getInitialValues(config, survey) {
if (!config.survey_enabled || !survey) {
return {};
}
const values = {};
const surveyValues = {};
survey.spec.forEach(question => {
if (question.type === 'multiselect') {
values[`survey_${question.variable}`] = question.default.split('\n');
if (question.default === '') {
surveyValues[`survey_${question.variable}`] = [];
} else {
surveyValues[`survey_${question.variable}`] = question.default.split(
'\n'
);
}
} else {
values[`survey_${question.variable}`] = question.default;
surveyValues[`survey_${question.variable}`] = question.default;
}
});
return values;
return surveyValues;
}

View File

@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react';
import { useFormikContext } from 'formik';
import useInventoryStep from './steps/useInventoryStep';
import useCredentialsStep from './steps/useCredentialsStep';
import useOtherPromptsStep from './steps/useOtherPromptsStep';
import useSurveyStep from './steps/useSurveyStep';
import usePreviewStep from './steps/usePreviewStep';
export default function useLaunchSteps(config, resource, i18n) {
const [visited, setVisited] = useState({});
const steps = [
useInventoryStep(config, visited, i18n),
useCredentialsStep(config, i18n),
useOtherPromptsStep(config, i18n),
useSurveyStep(config, visited, i18n),
];
const { resetForm, values: formikValues } = useFormikContext();
const hasErrors = steps.some(step => step.formError);
const surveyStepIndex = steps.findIndex(step => step.survey);
steps.push(
usePreviewStep(
config,
resource,
steps[surveyStepIndex]?.survey,
hasErrors,
i18n
)
);
const pfSteps = steps.map(s => s.step).filter(s => s != null);
const isReady = !steps.some(s => !s.isReady);
useEffect(() => {
if (surveyStepIndex > -1 && isReady) {
resetForm({
values: {
...formikValues,
...steps[surveyStepIndex].initialValues,
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isReady]);
const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null;
return {
steps: pfSteps,
isReady,
visitStep: stepId =>
setVisited({
...visited,
[stepId]: true,
}),
visitAllSteps: setFieldsTouched => {
setVisited({
inventory: true,
credentials: true,
other: true,
survey: true,
preview: true,
});
steps.forEach(s => s.setTouched(setFieldsTouched));
},
contentError,
};
}

View File

@@ -1,68 +0,0 @@
import { useState } from 'react';
import useInventoryStep from './steps/useInventoryStep';
import useCredentialsStep from './steps/useCredentialsStep';
import useOtherPromptsStep from './steps/useOtherPromptsStep';
import useSurveyStep from './steps/useSurveyStep';
import usePreviewStep from './steps/usePreviewStep';
export default function useSteps(config, resource, i18n) {
const [visited, setVisited] = useState({});
const steps = [
useInventoryStep(config, resource, visited, i18n),
useCredentialsStep(config, resource, visited, i18n),
useOtherPromptsStep(config, resource, visited, i18n),
useSurveyStep(config, resource, visited, i18n),
];
const formErrorsContent = steps
.filter(s => s?.formError && Object.keys(s.formError).length > 0)
.map(({ formError }) => formError);
steps.push(
usePreviewStep(config, resource, steps[3].survey, formErrorsContent, i18n)
);
const pfSteps = steps.map(s => s.step).filter(s => s != null);
const initialValues = steps.reduce((acc, cur) => {
return {
...acc,
...cur.initialValues,
};
}, {});
const isReady = !steps.some(s => !s.isReady);
const stepWithError = steps.find(s => s.contentError);
const contentError = stepWithError ? stepWithError.contentError : null;
const validate = values => {
const errors = steps.reduce((acc, cur) => {
return {
...acc,
...cur.validate(values),
};
}, {});
if (Object.keys(errors).length) {
return errors;
}
return false;
};
return {
steps: pfSteps,
initialValues,
isReady,
validate,
visitStep: stepId => setVisited({ ...visited, [stepId]: true }),
visitAllSteps: setFieldsTouched => {
setVisited({
inventory: true,
credentials: true,
other: true,
survey: true,
preview: true,
});
steps.forEach(s => s.setTouched(setFieldsTouched));
},
contentError,
};
}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { string, func } from 'prop-types';
import { Link } from 'react-router-dom';
import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import CaretDownIcon from '@patternfly/react-icons/dist/js/icons/caret-down-icon';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useKebabifiedMenu } from '../../contexts/Kebabified';
@@ -12,6 +13,7 @@ function ToolbarAddButton({
i18n,
isDisabled,
defaultLabel = i18n._(t`Add`),
showToggleIndicator,
}) {
const { isKebabified } = useKebabifiedMenu();
@@ -50,7 +52,13 @@ function ToolbarAddButton({
);
}
return (
<Button variant="primary" aria-label={defaultLabel} onClick={onClick}>
<Button
icon={showToggleIndicator ? <CaretDownIcon /> : null}
iconPosition={showToggleIndicator ? 'right' : null}
variant="primary"
aria-label={defaultLabel}
onClick={onClick}
>
{defaultLabel}
</Button>
);

View File

@@ -18,4 +18,13 @@ describe('<ToolbarAddButton />', () => {
expect(link).toHaveLength(1);
expect(link.prop('to')).toBe('/foo');
});
test('should render link with toggle icon', () => {
const wrapper = mountWithContexts(
<ToolbarAddButton showToggleIndicator linkTo="/foo" />
);
const link = wrapper.find('Link');
expect(link).toHaveLength(1);
expect(link.prop('to')).toBe('/foo');
});
});

View File

@@ -94,7 +94,7 @@ function ToolbarDeleteButton({
const renderTooltip = () => {
const itemsUnableToDelete = itemsToDelete
.filter(cannotDelete)
.map(item => item.name)
.map(item => item.name || item.username)
.join(', ');
if (itemsToDelete.some(cannotDelete)) {
return (

View File

@@ -12,6 +12,11 @@ const itemB = {
name: 'Foo',
summary_fields: { user_capabilities: { delete: false } },
};
const itemC = {
id: 1,
username: 'Foo',
summary_fields: { user_capabilities: { delete: false } },
};
describe('<ToolbarDeleteButton />', () => {
test('should render button', () => {
@@ -61,4 +66,14 @@ describe('<ToolbarDeleteButton />', () => {
expect(wrapper.find('Tooltip')).toHaveLength(1);
expect(wrapper.find('Tooltip').prop('content')).toEqual('Delete');
});
test('should render tooltip for username', () => {
const wrapper = mountWithContexts(
<ToolbarDeleteButton onDelete={() => {}} itemsToDelete={[itemC]} />
);
expect(wrapper.find('Tooltip')).toHaveLength(1);
expect(wrapper.find('Tooltip').prop('content').props.children).toEqual(
'You do not have permission to delete Items: Foo'
);
});
});

View File

@@ -135,7 +135,7 @@ function PromptJobTemplateDetail({ i18n, resource }) {
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[verbosity]} />
<Detail
label={i18n._(t`Show Changes`)}
value={diff_mode ? 'On' : 'Off'}
value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)}
/>
<Detail label={i18n._(t` Job Slicing`)} value={job_slice_count} />
<Detail label={i18n._(t`Host Config Key`)} value={host_config_key} />

View File

@@ -74,6 +74,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Name"
value="jane brown"
/>
@@ -86,6 +87,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Team Roles"
value={
<WithI18n
@@ -138,6 +140,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Name"
value="jane brown"
/>
@@ -150,6 +153,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Team Roles"
value={
<WithI18n
@@ -225,6 +229,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Name"
value="jane brown"
/>
@@ -237,6 +242,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Team Roles"
value={
<WithI18n
@@ -447,6 +453,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Name"
value="jane brown"
>
@@ -463,9 +470,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-htpNat",
"componentId": "sc-bxivhb",
"isStatic": false,
"lastClassName": "iYJcPm",
"lastClassName": "gQwVdc",
"rules": Array [
"
font-weight: var(--pf-global--FontWeight--bold);
@@ -478,7 +485,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"displayName": "Styled(Component)",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-htpNat",
"styledComponentId": "sc-bxivhb",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@@ -489,18 +496,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
fullWidth={false}
>
<Component
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
component="dt"
data-cy={null}
fullWidth={false}
>
<TextListItem
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
component="dt"
data-cy={null}
>
<dt
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
data-cy={null}
data-pf-content={true}
>
@@ -523,9 +530,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-bxivhb",
"componentId": "sc-ifAKCX",
"isStatic": false,
"lastClassName": "gxmPlV",
"lastClassName": "boHWLt",
"rules": Array [
"
word-break: break-all;
@@ -541,7 +548,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"displayName": "Styled(Component)",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-bxivhb",
"styledComponentId": "sc-ifAKCX",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@@ -552,18 +559,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
fullWidth={false}
>
<Component
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
component="dd"
data-cy={null}
fullWidth={false}
>
<TextListItem
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
component="dd"
data-cy={null}
>
<dd
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
data-cy={null}
data-pf-content={true}
>
@@ -670,6 +677,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
<Detail
alwaysVisible={false}
fullWidth={false}
helpText={null}
label="Team Roles"
value={
<WithI18n
@@ -703,9 +711,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-htpNat",
"componentId": "sc-bxivhb",
"isStatic": false,
"lastClassName": "iYJcPm",
"lastClassName": "gQwVdc",
"rules": Array [
"
font-weight: var(--pf-global--FontWeight--bold);
@@ -718,7 +726,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"displayName": "Styled(Component)",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-htpNat",
"styledComponentId": "sc-bxivhb",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@@ -729,18 +737,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
fullWidth={false}
>
<Component
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
component="dt"
data-cy={null}
fullWidth={false}
>
<TextListItem
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
component="dt"
data-cy={null}
>
<dt
className="sc-htpNat iYJcPm"
className="sc-bxivhb gQwVdc"
data-cy={null}
data-pf-content={true}
>
@@ -763,9 +771,9 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"$$typeof": Symbol(react.forward_ref),
"attrs": Array [],
"componentStyle": ComponentStyle {
"componentId": "sc-bxivhb",
"componentId": "sc-ifAKCX",
"isStatic": false,
"lastClassName": "gxmPlV",
"lastClassName": "boHWLt",
"rules": Array [
"
word-break: break-all;
@@ -781,7 +789,7 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
"displayName": "Styled(Component)",
"foldedComponentIds": Array [],
"render": [Function],
"styledComponentId": "sc-bxivhb",
"styledComponentId": "sc-ifAKCX",
"target": [Function],
"toString": [Function],
"warnTooManyClasses": [Function],
@@ -792,18 +800,18 @@ exports[`<ResourceAccessListItem /> initially renders succesfully 1`] = `
fullWidth={false}
>
<Component
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
component="dd"
data-cy={null}
fullWidth={false}
>
<TextListItem
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
component="dd"
data-cy={null}
>
<dd
className="sc-bxivhb gxmPlV"
className="sc-ifAKCX boHWLt"
data-cy={null}
data-pf-content={true}
>

View File

@@ -32,11 +32,12 @@ function RoutedTabs(props) {
<Tabs activeKey={getActiveTabId()} onSelect={handleTabSelect}>
{tabsArray.map(tab => (
<Tab
aria-label={`${tab.name}`}
aria-label={typeof tab.name === 'string' ? tab.name : ''}
eventKey={tab.id}
key={tab.id}
link={tab.link}
title={<TabTitleText>{tab.name}</TabTitleText>}
role="tab"
/>
))}
</Tabs>

View File

@@ -225,7 +225,7 @@ function ScheduleDetail({ schedule, i18n }) {
{ask_diff_mode_on_launch && typeof diff_mode === 'boolean' && (
<Detail
label={i18n._(t`Show Changes`)}
value={diff_mode ? 'On' : 'Off'}
value={diff_mode ? i18n._(t`On`) : i18n._(t`Off`)}
/>
)}
{ask_credential_on_launch && (

View File

@@ -93,42 +93,43 @@ SkippedBottom.displayName = 'SkippedBottom';
const StatusIcon = ({ status, ...props }) => {
return (
<div {...props} data-job-status={status}>
{status === 'running' && <RunningJob />}
<div {...props} data-job-status={status} aria-label={status}>
{status === 'running' && <RunningJob aria-hidden="true" />}
{(status === 'new' ||
status === 'pending' ||
status === 'waiting' ||
status === 'never updated') && <WaitingJob />}
status === 'never updated') && <WaitingJob aria-hidden="true" />}
{(status === 'failed' || status === 'error' || status === 'canceled') && (
<FinishedJob>
<FinishedJob aria-hidden="true">
<FailedTop />
<FailedBottom />
</FinishedJob>
)}
{(status === 'successful' || status === 'ok') && (
<FinishedJob>
<FinishedJob aria-hidden="true">
<SuccessfulTop />
<SuccessfulBottom />
</FinishedJob>
)}
{status === 'changed' && (
<FinishedJob>
<FinishedJob aria-hidden="true">
<ChangedTop />
<ChangedBottom />
</FinishedJob>
)}
{status === 'skipped' && (
<FinishedJob>
<FinishedJob aria-hidden="true">
<SkippedTop />
<SkippedBottom />
</FinishedJob>
)}
{status === 'unreachable' && (
<FinishedJob>
<FinishedJob aria-hidden="true">
<UnreachableTop />
<UnreachableBottom />
</FinishedJob>
)}
<span className="pf-screen-reader"> {status} </span>
</div>
);
};

View File

@@ -29,13 +29,21 @@ export default function SyncStatusIndicator({ status, title }) {
if (status === 'syncing') {
return (
<PulseWrapper>
<CloudIcon color={`var(${color})`} title={title} />
</PulseWrapper>
<>
<PulseWrapper aria-hidden="true">
<CloudIcon color={`var(${color})`} title={title} />
</PulseWrapper>
<span className="pf-screen-reader">{status}</span>
</>
);
}
return <CloudIcon color={`var(${color})`} title={title} />;
return (
<>
<CloudIcon color={`var(${color})`} title={title} aria-hidden="true" />
<span className="pf-screen-reader">{status}</span>
</>
);
}
SyncStatusIndicator.propTypes = {
status: oneOf(['success', 'error', 'disabled', 'syncing']).isRequired,

View File

@@ -0,0 +1,6 @@
import React, { useContext } from 'react';
export const SettingsContext = React.createContext({});
export const SettingsProvider = SettingsContext.Provider;
export const useSettings = () => useContext(SettingsContext);

View File

@@ -48,15 +48,17 @@ function ContainerGroupDetails({ instanceGroup, i18n }) {
value={i18n._(t`Container group`)}
dataCy="container-group-type"
/>
<Detail
label={i18n._(t`Credential`)}
value={
<Label variant="outline" color="blue">
{instanceGroup.summary_fields.credential.name}
</Label>
}
dataCy="container-group-credential"
/>
{instanceGroup.summary_fields.credential && (
<Detail
label={i18n._(t`Credential`)}
value={
<Label variant="outline" color="blue">
{instanceGroup.summary_fields.credential?.name}
</Label>
}
dataCy="container-group-credential"
/>
)}
<UserDateDetail
label={i18n._(t`Created`)}
date={instanceGroup.created}

View File

@@ -23,9 +23,11 @@ import { VariablesField } from '../../../components/CodeMirrorInput';
function ContainerGroupFormFields({ i18n, instanceGroup }) {
const { setFieldValue } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
const [credentialField, credentialMeta, credentialHelpers] = useField({
name: 'credential',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const [overrideField] = useField('override');
const onCredentialChange = useCallback(

View File

@@ -28,12 +28,21 @@ const instanceGroup = {
is_controller: false,
is_isolated: false,
is_containerized: false,
credential: null,
credential: 3,
policy_instance_percentage: 46,
policy_instance_minimum: 12,
policy_instance_list: [],
pod_spec_override: '',
summary_fields: {
credential: {
id: 3,
name: 'test',
description: 'Simple one',
kind: 'kubernetes_bearer_token',
cloud: false,
kubernetes: true,
credential_type_id: 17,
},
user_capabilities: {
edit: true,
delete: true,
@@ -101,7 +110,7 @@ describe('<ContainerGroupForm/>', () => {
.find('Checkbox[aria-label="Customize pod specification"]')
.prop('isChecked')
).toBeFalsy();
expect(wrapper.find('CredentialLookup').prop('value')).toBeFalsy();
expect(wrapper.find('CredentialLookup').prop('value').name).toBe('test');
});
test('should update form values', () => {

View File

@@ -2,8 +2,14 @@ import React, { useEffect, useCallback, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
Tooltip,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import { getQSConfig, mergeParams, parseQueryString } from '../../../util/qs';
import { GroupsAPI, InventoriesAPI } from '../../../api';
import { GroupsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import useRequest, {
useDeleteItems,
@@ -16,6 +22,8 @@ import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList from '../../../components/PaginatedDataList';
import AssociateModal from '../../../components/AssociateModal';
import DisassociateButton from '../../../components/DisassociateButton';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryGroupHostListItem from './InventoryGroupHostListItem';
import AddHostDropdown from './AddHostDropdown';
@@ -27,6 +35,7 @@ const QS_CONFIG = getQSConfig('host', {
function InventoryGroupHostList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const { id: inventoryId, groupId } = useParams();
const location = useLocation();
const history = useHistory();
@@ -38,6 +47,9 @@ function InventoryGroupHostList({ i18n }) {
actions,
relatedSearchableKeys,
searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
},
error: contentError,
isLoading,
@@ -45,9 +57,16 @@ function InventoryGroupHostList({ i18n }) {
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, actionsResponse] = await Promise.all([
const [
response,
actionsResponse,
adHocOptions,
cred,
] = await Promise.all([
GroupsAPI.readAllHosts(groupId, params),
InventoriesAPI.readHostsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return {
@@ -60,6 +79,9 @@ function InventoryGroupHostList({ i18n }) {
searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
};
}, [groupId, inventoryId, location.search]),
{
@@ -68,6 +90,8 @@ function InventoryGroupHostList({ i18n }) {
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
}
);
@@ -195,6 +219,40 @@ function InventoryGroupHostList({ i18n }) {
/>,
]
: []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<DropdownItem
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it.
The inventory source can be a single host or a selection of multiple hosts.`
)}
position="top"
key="adhoc"
>
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<DisassociateButton
key="disassociate"
onDisassociate={handleDisassociate}
@@ -222,6 +280,7 @@ function InventoryGroupHostList({ i18n }) {
emptyStateControls={
canAdd && (
<AddHostDropdown
key="associate"
onAddExisting={() => setIsModalOpen(true)}
onAddNew={() => history.push(addFormUrl)}
/>
@@ -239,6 +298,16 @@ function InventoryGroupHostList({ i18n }) {
title={i18n._(t`Select Hosts`)}
/>
)}
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(inventoryId, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
{associateError && (
<AlertModal
isOpen={associateError}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { GroupsAPI, InventoriesAPI } from '../../../api';
import { GroupsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
@@ -11,6 +11,7 @@ import mockHosts from '../shared/data.hosts.json';
jest.mock('../../../api/models/Groups');
jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/CredentialTypes');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
@@ -34,6 +35,17 @@ describe('<InventoryGroupHostList />', () => {
},
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupHostList />);
});
@@ -95,6 +107,29 @@ describe('<InventoryGroupHostList />', () => {
});
});
test('should render enabled ad hoc commands button', async () => {
GroupsAPI.readAllHosts.mockResolvedValue({
data: { ...mockHosts },
});
InventoriesAPI.readHostsOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
},
});
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupHostList />);
});
await waitForElement(
wrapper,
'button[aria-label="Run command"]',
el => el.prop('disabled') === false
);
});
test('should show add dropdown button according to permissions', async () => {
expect(wrapper.find('AddHostDropdown').length).toBe(1);
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({

View File

@@ -12,7 +12,7 @@ import {
import { getQSConfig, parseQueryString } from '../../../util/qs';
import useSelected from '../../../util/useSelected';
import useRequest from '../../../util/useRequest';
import { InventoriesAPI, GroupsAPI } from '../../../api';
import { InventoriesAPI, GroupsAPI, CredentialTypesAPI } from '../../../api';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
import DataListToolbar from '../../../components/DataListToolbar';
@@ -22,7 +22,8 @@ import PaginatedDataList, {
import InventoryGroupItem from './InventoryGroupItem';
import InventoryGroupsDeleteModal from '../shared/InventoryGroupsDeleteModal';
import AdHocCommandsButton from '../../../components/AdHocCommands/AdHocCommands';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import { Kebabified } from '../../../contexts/Kebabified';
const QS_CONFIG = getQSConfig('group', {
@@ -51,6 +52,7 @@ const useModal = () => {
function InventoryGroupsList({ i18n }) {
const [deletionError, setDeletionError] = useState(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const location = useLocation();
const { isModalOpen, toggleModal } = useModal();
const { id: inventoryId } = useParams();
@@ -62,27 +64,36 @@ function InventoryGroupsList({ i18n }) {
actions,
relatedSearchableKeys,
searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
},
error: contentError,
isLoading,
request: fetchGroups,
request: fetchData,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const [response, actionsResponse] = await Promise.all([
const [response, groupOptions, adHocOptions, cred] = await Promise.all([
InventoriesAPI.readGroups(inventoryId, params),
InventoriesAPI.readGroupsOptions(inventoryId),
InventoriesAPI.readAdHocOptions(inventoryId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return {
groups: response.data.results,
groupCount: response.data.count,
actions: actionsResponse.data.actions,
actions: groupOptions.data.actions,
relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || []
groupOptions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
groupOptions.data.actions?.GET || {}
).filter(key => groupOptions.data.actions?.GET[key].filterable),
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
};
}, [inventoryId, location]),
{
@@ -95,8 +106,8 @@ function InventoryGroupsList({ i18n }) {
);
useEffect(() => {
fetchGroups();
}, [fetchGroups]);
fetchData();
}, [fetchData]);
const { selected, isAllSelected, handleSelect, setSelected } = useSelected(
groups
@@ -144,7 +155,7 @@ function InventoryGroupsList({ i18n }) {
}
toggleModal();
fetchGroups();
fetchData();
setSelected([]);
setIsDeleteLoading(false);
};
@@ -153,21 +164,14 @@ function InventoryGroupsList({ i18n }) {
const kebabedAdditionalControls = () => {
return (
<>
<AdHocCommandsButton
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(inventoryId, 10)}
<DropdownItem
key="run command"
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={groupCount === 0 || isAdHocDisabled}
>
{({ openAdHocCommands }) => (
<DropdownItem
key="run command"
onClick={openAdHocCommands}
isDisabled={groupCount === 0}
>
{i18n._(t`Run command`)}
</DropdownItem>
)}
</AdHocCommandsButton>
{i18n._(t`Run command`)}
</DropdownItem>
<DropdownItem
variant="danger"
aria-label={i18n._(t`Delete`)}
@@ -264,23 +268,14 @@ function InventoryGroupsList({ i18n }) {
position="top"
key="adhoc"
>
<AdHocCommandsButton
css="margin-right: 20px"
adHocItems={selected}
apiModule={InventoriesAPI}
itemId={parseInt(inventoryId, 10)}
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={groupCount === 0 || isAdHocDisabled}
>
{({ openAdHocCommands }) => (
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={openAdHocCommands}
isDisabled={groupCount === 0}
>
{i18n._(t`Run command`)}
</Button>
)}
</AdHocCommandsButton>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
<ToolbarItem>
@@ -321,6 +316,16 @@ function InventoryGroupsList({ i18n }) {
)
}
/>
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(inventoryId, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
{deletionError && (
<AlertModal
isOpen={deletionError}

View File

@@ -6,7 +6,7 @@ import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { InventoriesAPI, GroupsAPI } from '../../../api';
import { InventoriesAPI, GroupsAPI, CredentialTypesAPI } from '../../../api';
import InventoryGroupsList from './InventoryGroupsList';
jest.mock('../../../api');
@@ -71,6 +71,17 @@ describe('<InventoryGroupsList />', () => {
},
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'],
});
@@ -147,31 +158,17 @@ describe('<InventoryGroupsList />', () => {
expect(el.props().checked).toBe(false);
});
});
test('should render enabled ad hoc commands button', async () => {
await waitForElement(
wrapper,
'button[aria-label="Run command"]',
el => el.prop('disabled') === false
);
});
});
describe('<InventoryGroupsList/> error handling', () => {
let wrapper;
test('should show content error when api throws error on initial render', async () => {
InventoriesAPI.readGroupsOptions.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
});
test('should show content error if groups are not successfully fetched from api', async () => {
InventoriesAPI.readGroups.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
});
test('should show error modal when group is not successfully deleted from api', async () => {
beforeEach(() => {
InventoriesAPI.readGroups.mockResolvedValue({
data: {
count: mockGroups.length,
@@ -197,7 +194,42 @@ describe('<InventoryGroupsList/> error handling', () => {
},
})
);
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
});
afterEach(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should show content error when api throws error on initial render', async () => {
InventoriesAPI.readGroupsOptions.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
});
test('should show content error if groups are not successfully fetched from api', async () => {
InventoriesAPI.readGroups.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<InventoryGroupsList />);
});
await waitForElement(wrapper, 'ContentError', el => el.length > 0);
});
test('should show error modal when group is not successfully deleted from api', async () => {
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'],
});
@@ -249,4 +281,27 @@ describe('<InventoryGroupsList/> error handling', () => {
.invoke('onClose')();
});
});
test('should render disabled ad hoc button', async () => {
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/3/groups'],
});
await act(async () => {
wrapper = mountWithContexts(
<Route path="/inventories/inventory/:id/groups">
<InventoryGroupsList />
</Route>,
{
context: {
router: { history, route: { location: history.location } },
},
}
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(
wrapper.find('button[aria-label="Run command"]').prop('disabled')
).toBe(true);
});
});

View File

@@ -2,13 +2,19 @@ import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
Tooltip,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import { getQSConfig, parseQueryString, mergeParams } from '../../../util/qs';
import useRequest, {
useDismissableError,
useDeleteItems,
} from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import { HostsAPI, InventoriesAPI } from '../../../api';
import { HostsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import DataListToolbar from '../../../components/DataListToolbar';
import AlertModal from '../../../components/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail';
@@ -17,6 +23,8 @@ import PaginatedDataList, {
} from '../../../components/PaginatedDataList';
import AssociateModal from '../../../components/AssociateModal';
import DisassociateButton from '../../../components/DisassociateButton';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostGroupItem from './InventoryHostGroupItem';
const QS_CONFIG = getQSConfig('group', {
@@ -27,6 +35,7 @@ const QS_CONFIG = getQSConfig('group', {
function InventoryHostGroupsList({ i18n }) {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const { hostId, id: invId } = useParams();
const { search } = useLocation();
@@ -37,6 +46,9 @@ function InventoryHostGroupsList({ i18n }) {
actions,
relatedSearchableKeys,
searchableKeys,
moduleOptions,
isAdHocDisabled,
credentialTypeId,
},
error: contentError,
isLoading,
@@ -49,22 +61,29 @@ function InventoryHostGroupsList({ i18n }) {
{
data: { count, results },
},
actionsResponse,
hostGroupOptions,
adHocOptions,
cred,
] = await Promise.all([
HostsAPI.readAllGroups(hostId, params),
HostsAPI.readGroupsOptions(hostId),
InventoriesAPI.readAdHocOptions(invId),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return {
groups: results,
itemCount: count,
actions: actionsResponse.data.actions,
actions: hostGroupOptions.data.actions,
relatedSearchableKeys: (
actionsResponse?.data?.related_search_fields || []
hostGroupOptions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
hostGroupOptions.data.actions?.GET || {}
).filter(key => hostGroupOptions.data.actions?.GET[key].filterable),
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
};
}, [hostId, search]), // eslint-disable-line react-hooks/exhaustive-deps
{
@@ -73,6 +92,8 @@ function InventoryHostGroupsList({ i18n }) {
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
}
);
@@ -201,6 +222,40 @@ function InventoryHostGroupsList({ i18n }) {
/>,
]
: []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<DropdownItem
key="run command"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={itemCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single group or host, a selection of multiple hosts, or a selection of multiple groups.`
)}
position="top"
key="adhoc"
>
<Button
key="run command"
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={itemCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<DisassociateButton
key="disassociate"
onDisassociate={handleDisassociate}
@@ -208,8 +263,8 @@ function InventoryHostGroupsList({ i18n }) {
modalTitle={i18n._(t`Disassociate group from host?`)}
modalNote={i18n._(t`
Note that you may still see the group in the list after
disassociating if the host is also a member of that groups
children. This list shows all groups the host is associated
disassociating if the host is also a member of that groups
children. This list shows all groups the host is associated
with directly and indirectly.
`)}
/>,
@@ -233,6 +288,16 @@ function InventoryHostGroupsList({ i18n }) {
title={i18n._(t`Select Groups`)}
/>
)}
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(invId, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
{error && (
<AlertModal
isOpen={error}

View File

@@ -6,7 +6,7 @@ import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { HostsAPI, InventoriesAPI } from '../../../api';
import { HostsAPI, InventoriesAPI, CredentialTypesAPI } from '../../../api';
import InventoryHostGroupsList from './InventoryHostGroupsList';
jest.mock('../../../api');
@@ -80,6 +80,17 @@ describe('<InventoryHostGroupsList />', () => {
},
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
const history = createMemoryHistory({
initialEntries: ['/inventories/inventory/1/hosts/3/groups'],
});
@@ -272,4 +283,11 @@ describe('<InventoryHostGroupsList />', () => {
wrapper.update();
expect(wrapper.find('AlertModal ErrorDetail').length).toBe(1);
});
test('should render enabled ad hoc commands button', async () => {
await waitForElement(
wrapper,
'button[aria-label="Run command"]',
el => el.prop('disabled') === false
);
});
});

View File

@@ -1,10 +1,16 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
Tooltip,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import { InventoriesAPI, HostsAPI } from '../../../api';
import { InventoriesAPI, HostsAPI, CredentialTypesAPI } from '../../../api';
import useRequest, { useDeleteItems } from '../../../util/useRequest';
import AlertModal from '../../../components/AlertModal';
import DataListToolbar from '../../../components/DataListToolbar';
import ErrorDetail from '../../../components/ErrorDetail';
@@ -12,6 +18,8 @@ import PaginatedDataList, {
ToolbarAddButton,
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
import InventoryHostItem from './InventoryHostItem';
const QS_CONFIG = getQSConfig('host', {
@@ -21,48 +29,64 @@ const QS_CONFIG = getQSConfig('host', {
});
function InventoryHostList({ i18n }) {
const [actions, setActions] = useState(null);
const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
const [hostCount, setHostCount] = useState(0);
const [hosts, setHosts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const [selected, setSelected] = useState([]);
const { id } = useParams();
const { search } = useLocation();
const fetchHosts = (hostId, queryString) => {
const params = parseQueryString(QS_CONFIG, queryString);
return InventoriesAPI.readHosts(hostId, params);
};
const {
result: {
hosts,
hostCount,
actions,
relatedSearchableKeys,
searchableKeys,
moduleOptions,
credentialTypeId,
isAdHocDisabled,
},
error: contentError,
isLoading,
request: fetchData,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, search);
const [response, hostOptions, adHocOptions, cred] = await Promise.all([
InventoriesAPI.readHosts(id, params),
InventoriesAPI.readHostsOptions(id),
InventoriesAPI.readAdHocOptions(id),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return {
hosts: response.data.results,
hostCount: response.data.count,
actions: hostOptions.data.actions,
relatedSearchableKeys: (
hostOptions?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(hostOptions.data.actions?.GET || {}).filter(
key => hostOptions.data.actions?.GET[key].filterable
),
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
};
}, [id, search]),
{
hosts: [],
hostCount: 0,
actions: {},
relatedSearchableKeys: [],
searchableKeys: [],
moduleOptions: [],
isAdHocDisabled: true,
}
);
useEffect(() => {
async function fetchData() {
try {
const [
{
data: { count, results },
},
{
data: { actions: optionActions },
},
] = await Promise.all([
fetchHosts(id, search),
InventoriesAPI.readOptions(),
]);
setHosts(results);
setHostCount(count);
setActions(optionActions);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
fetchData();
}, [id, search]);
}, [fetchData]);
const handleSelectAll = isSelected => {
setSelected(isSelected ? [...hosts] : []);
@@ -75,30 +99,17 @@ function InventoryHostList({ i18n }) {
setSelected(selected.concat(row));
}
};
const handleDelete = async () => {
setIsLoading(true);
try {
const {
isLoading: isDeleteLoading,
deleteItems: deleteHosts,
deletionError,
clearDeletionError,
} = useDeleteItems(
useCallback(async () => {
await Promise.all(selected.map(host => HostsAPI.destroy(host.id)));
} catch (error) {
setDeletionError(error);
} finally {
setSelected([]);
try {
const {
data: { count, results },
} = await fetchHosts(id, search);
setHosts(results);
setHostCount(count);
} catch (error) {
setContentError(error);
} finally {
setIsLoading(false);
}
}
};
}, [selected]),
{ qsConfig: QS_CONFIG, fetchItems: fetchData }
);
const canAdd =
actions && Object.prototype.hasOwnProperty.call(actions, 'POST');
@@ -108,7 +119,7 @@ function InventoryHostList({ i18n }) {
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
hasContentLoading={isLoading || isDeleteLoading}
items={hosts}
itemCount={hostCount}
pluralizedItemName={i18n._(t`Hosts`)}
@@ -133,6 +144,8 @@ function InventoryHostList({ i18n }) {
isNumeric: true,
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (
<DataListToolbar
{...props}
@@ -149,9 +162,43 @@ function InventoryHostList({ i18n }) {
/>,
]
: []),
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<DropdownItem
key="run command"
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
aria-label={i18n._(t`Run command`)}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single host or a selection of multiple hosts.`
)}
position="top"
key="adhoc"
>
<Button
variant="secondary"
key="run command"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={hostCount === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
<ToolbarDeleteButton
key="delete"
onDelete={handleDelete}
onDelete={deleteHosts}
itemsToDelete={selected}
pluralizedItemName={i18n._(t`Hosts`)}
/>,
@@ -177,12 +224,22 @@ function InventoryHostList({ i18n }) {
)
}
/>
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
itemId={id}
/>
)}
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="error"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
onClose={clearDeletionError}
>
{i18n._(t`Failed to delete one or more hosts.`)}
<ErrorDetail error={deletionError} />

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { InventoriesAPI, HostsAPI } from '../../../api';
import { InventoriesAPI, HostsAPI, CredentialTypesAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
@@ -85,7 +85,7 @@ describe('<InventoryHostList />', () => {
results: mockHosts,
},
});
InventoriesAPI.readOptions.mockResolvedValue({
InventoriesAPI.readHostsOptions.mockResolvedValue({
data: {
actions: {
GET: {},
@@ -93,6 +93,17 @@ describe('<InventoryHostList />', () => {
},
},
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
POST: {},
},
},
});
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
await act(async () => {
wrapper = mountWithContexts(<InventoryHostList />);
});
@@ -265,8 +276,15 @@ describe('<InventoryHostList />', () => {
expect(wrapper.find('ToolbarAddButton').length).toBe(1);
});
test('should render enabled ad hoc commands button', async () => {
await waitForElement(
wrapper,
'button[aria-label="Run command"]',
el => el.prop('disabled') === false
);
});
test('should hide Add button for users without ability to POST', async () => {
InventoriesAPI.readOptions.mockResolvedValueOnce({
InventoriesAPI.readHostsOptions.mockResolvedValueOnce({
data: {
actions: {
GET: {},
@@ -283,7 +301,7 @@ describe('<InventoryHostList />', () => {
});
test('should show content error when api throws error on initial render', async () => {
InventoriesAPI.readOptions.mockImplementation(() =>
InventoriesAPI.readHostsOptions.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {

View File

@@ -10,6 +10,7 @@ import {
DataListItemRow,
Label,
Tooltip,
Badge as PFBadge,
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons';
import { t } from '@lingui/macro';
@@ -29,6 +30,15 @@ const DataListAction = styled(_DataListAction)`
grid-template-columns: repeat(2, 40px);
`;
const Badge = styled(PFBadge)`
margin-left: 8px;
`;
const ListGroup = styled.div`
margin-left: 8px;
display: inline-block;
`;
function InventoryListItem({
inventory,
isSelected,
@@ -102,6 +112,20 @@ function InventoryListItem({
? i18n._(t`Smart Inventory`)
: i18n._(t`Inventory`)}
</DataListCell>,
<DataListCell key="groups-hosts-sources-counts">
<ListGroup>
{i18n._(t`Groups`)}
<Badge isRead>{inventory.total_groups}</Badge>
</ListGroup>
<ListGroup>
{i18n._(t`Hosts`)}
<Badge isRead>{inventory.total_hosts}</Badge>
</ListGroup>
<ListGroup>
{i18n._(t`Sources`)}
<Badge isRead>{inventory.total_inventory_sources}</Badge>
</ListGroup>
</DataListCell>,
inventory.pending_deletion && (
<DataListCell alignRight isFilled={false} key="pending-delete">
<Label color="red">{i18n._(t`Pending delete`)}</Label>

View File

@@ -1,16 +1,23 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Button } from '@patternfly/react-core';
import {
Button,
Tooltip,
DropdownItem,
ToolbarItem,
} from '@patternfly/react-core';
import DataListToolbar from '../../../components/DataListToolbar';
import PaginatedDataList from '../../../components/PaginatedDataList';
import SmartInventoryHostListItem from './SmartInventoryHostListItem';
import useRequest from '../../../util/useRequest';
import useSelected from '../../../util/useSelected';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import { InventoriesAPI } from '../../../api';
import { InventoriesAPI, CredentialTypesAPI } from '../../../api';
import { Inventory } from '../../../types';
import { Kebabified } from '../../../contexts/Kebabified';
import AdHocCommands from '../../../components/AdHocCommands/AdHocCommands';
const QS_CONFIG = getQSConfig('host', {
page: 1,
@@ -20,24 +27,35 @@ const QS_CONFIG = getQSConfig('host', {
function SmartInventoryHostList({ i18n, inventory }) {
const location = useLocation();
const [isAdHocCommandsOpen, setIsAdHocCommandsOpen] = useState(false);
const {
result: { hosts, count },
result: { hosts, count, moduleOptions, credentialTypeId, isAdHocDisabled },
error: contentError,
isLoading,
request: fetchHosts,
} = useRequest(
useCallback(async () => {
const params = parseQueryString(QS_CONFIG, location.search);
const { data } = await InventoriesAPI.readHosts(inventory.id, params);
const [hostResponse, adHocOptions, cred] = await Promise.all([
InventoriesAPI.readHosts(inventory.id, params),
InventoriesAPI.readAdHocOptions(inventory.id),
CredentialTypesAPI.read({ namespace: 'ssh' }),
]);
return {
hosts: data.results,
count: data.count,
hosts: hostResponse.data.results,
count: hostResponse.data.count,
moduleOptions: adHocOptions.data.actions.GET.module_name.choices,
credentialTypeId: cred.data.results[0].id,
isAdHocDisabled: !adHocOptions.data.actions.POST,
};
}, [location.search, inventory.id]),
{
hosts: [],
count: 0,
moduleOptions: [],
isAdHocDisabled: true,
}
);
@@ -50,66 +68,106 @@ function SmartInventoryHostList({ i18n, inventory }) {
}, [fetchHosts]);
return (
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={hosts}
itemCount={count}
pluralizedItemName={i18n._(t`Hosts`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created by (username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified by (username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected => setSelected(isSelected ? [...hosts] : [])}
qsConfig={QS_CONFIG}
additionalControls={
inventory?.summary_fields?.user_capabilities?.adhoc
? [
<Button
aria-label={i18n._(t`Run commands`)}
isDisabled={selected.length === 0}
>
{i18n._(t`Run commands`)}
</Button>,
]
: []
}
<>
<PaginatedDataList
contentError={contentError}
hasContentLoading={isLoading}
items={hosts}
itemCount={count}
pluralizedItemName={i18n._(t`Hosts`)}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
isDefault: true,
},
{
name: i18n._(t`Created by (username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified by (username)`),
key: 'modified_by__username',
},
]}
toolbarSortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
renderToolbar={props => (
<DataListToolbar
{...props}
showSelectAll
isAllSelected={isAllSelected}
onSelectAll={isSelected =>
setSelected(isSelected ? [...hosts] : [])
}
qsConfig={QS_CONFIG}
additionalControls={
inventory?.summary_fields?.user_capabilities?.adhoc
? [
<Kebabified>
{({ isKebabified }) =>
isKebabified ? (
<DropdownItem
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={count === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</DropdownItem>
) : (
<ToolbarItem>
<Tooltip
content={i18n._(
t`Select an inventory source by clicking the check box beside it. The inventory source can be a single host or a selection of multiple hosts.`
)}
position="top"
key="adhoc"
>
<Button
variant="secondary"
aria-label={i18n._(t`Run command`)}
onClick={() => setIsAdHocCommandsOpen(true)}
isDisabled={count === 0 || isAdHocDisabled}
>
{i18n._(t`Run command`)}
</Button>
</Tooltip>
</ToolbarItem>
)
}
</Kebabified>,
]
: []
}
/>
)}
renderItem={host => (
<SmartInventoryHostListItem
key={host.id}
host={host}
detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`}
isSelected={selected.some(row => row.id === host.id)}
onSelect={() => handleSelect(host)}
/>
)}
/>
{isAdHocCommandsOpen && (
<AdHocCommands
css="margin-right: 20px"
adHocItems={selected}
itemId={parseInt(inventory.id, 10)}
onClose={() => setIsAdHocCommandsOpen(false)}
credentialTypeId={credentialTypeId}
moduleOptions={moduleOptions}
/>
)}
renderItem={host => (
<SmartInventoryHostListItem
key={host.id}
host={host}
detailUrl={`/inventories/smart_inventory/${inventory.id}/hosts/${host.id}/details`}
isSelected={selected.some(row => row.id === host.id)}
onSelect={() => handleSelect(host)}
/>
)}
/>
</>
);
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { InventoriesAPI } from '../../../api';
import { InventoriesAPI, CredentialTypesAPI } from '../../../api';
import {
mountWithContexts,
waitForElement,
@@ -12,125 +12,109 @@ import mockHosts from '../shared/data.hosts.json';
jest.mock('../../../api');
describe('<SmartInventoryHostList />', () => {
describe('User has adhoc permissions', () => {
let wrapper;
const clonedInventory = {
...mockInventory,
summary_fields: {
...mockInventory.summary_fields,
user_capabilities: {
...mockInventory.summary_fields.user_capabilities,
let wrapper;
const clonedInventory = {
...mockInventory,
summary_fields: {
...mockInventory.summary_fields,
user_capabilities: {
...mockInventory.summary_fields.user_capabilities,
},
},
};
beforeAll(async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: mockHosts,
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
POST: {},
},
},
};
beforeAll(async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: mockHosts,
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
CredentialTypesAPI.read.mockResolvedValue({
data: { count: 1, results: [{ id: 1, name: 'cred' }] },
});
test('initially renders successfully', () => {
expect(wrapper.find('SmartInventoryHostList').length).toBe(1);
});
test('should fetch hosts from api and render them in the list', () => {
expect(InventoriesAPI.readHosts).toHaveBeenCalled();
expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3);
});
test('should disable run commands button when no hosts are selected', () => {
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
const runCommandsButton = wrapper.find(
'button[aria-label="Run commands"]'
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} />
);
expect(runCommandsButton.length).toBe(1);
expect(runCommandsButton.prop('disabled')).toEqual(true);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('should enable run commands button when at least one host is selected', () => {
act(() => {
wrapper.find('DataListCheck[id="select-host-2"]').invoke('onChange')(
true
);
});
wrapper.update();
const runCommandsButton = wrapper.find(
'button[aria-label="Run commands"]'
);
expect(runCommandsButton.prop('disabled')).toEqual(false);
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('initially renders successfully', () => {
expect(wrapper.find('SmartInventoryHostList').length).toBe(1);
});
test('should fetch hosts from api and render them in the list', () => {
expect(InventoriesAPI.readHosts).toHaveBeenCalled();
expect(wrapper.find('SmartInventoryHostListItem').length).toBe(3);
});
test('should have run command button', () => {
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toBe(false);
});
const runCommandsButton = wrapper.find('button[aria-label="Run command"]');
expect(runCommandsButton.length).toBe(1);
expect(runCommandsButton.prop('disabled')).toBe(false);
});
test('should select and deselect all items', async () => {
act(() => {
wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toEqual(true);
});
act(() => {
wrapper.find('DataListToolbar').invoke('onSelectAll')(false);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toEqual(false);
});
test('should select and deselect all items', async () => {
act(() => {
wrapper.find('DataListToolbar').invoke('onSelectAll')(true);
});
test('should show content error when api throws an error', async () => {
InventoriesAPI.readHosts.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={mockInventory} />
);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toEqual(true);
});
act(() => {
wrapper.find('DataListToolbar').invoke('onSelectAll')(false);
});
wrapper.update();
wrapper.find('DataListCheck').forEach(el => {
expect(el.props().checked).toEqual(false);
});
});
describe('User does not have adhoc permissions', () => {
let wrapper;
const clonedInventory = {
...mockInventory,
summary_fields: {
user_capabilities: {
adhoc: false,
test('should show content error when api throws an error', async () => {
InventoriesAPI.readHosts.mockImplementation(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={mockInventory} />
);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
test('should disable run commands button', async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: { results: [], count: 0 },
});
InventoriesAPI.readAdHocOptions.mockResolvedValue({
data: {
actions: {
GET: { module_name: { choices: [['module']] } },
},
},
};
test('should hide run commands button', async () => {
InventoriesAPI.readHosts.mockResolvedValue({
data: { results: [], count: 0 },
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const runCommandsButton = wrapper.find(
'button[aria-label="Run commands"]'
);
expect(runCommandsButton.length).toBe(0);
jest.clearAllMocks();
wrapper.unmount();
});
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryHostList inventory={clonedInventory} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const runCommandsButton = wrapper.find('button[aria-label="Run command"]');
expect(runCommandsButton.prop('disabled')).toBe(true);
});
});

View File

@@ -47,6 +47,7 @@ const InventorySourceFormFields = ({ source, sourceOptions, i18n }) => {
values,
initialValues,
resetForm,
setFieldTouched,
setFieldValue,
} = useFormikContext();
const [sourceField, sourceMeta] = useField({
@@ -92,6 +93,7 @@ const InventorySourceFormFields = ({ source, sourceOptions, i18n }) => {
};
Object.keys(defaults).forEach(label => {
setFieldValue(label, defaults[label]);
setFieldTouched(label, false);
});
}
};
@@ -255,7 +257,7 @@ const InventorySourceForm = ({
overwrite: source?.overwrite || false,
overwrite_vars: source?.overwrite_vars || false,
source: source?.source || '',
source_path: source?.source_path === '' ? '/ (project root)' : '',
source_path: source?.source_path || '',
source_project: source?.summary_fields?.source_project || null,
source_script: source?.summary_fields?.source_script || null,
source_vars: source?.source_vars || '---\n',

View File

@@ -11,12 +11,14 @@ import {
EnabledValueField,
HostFilterField,
} from './SharedFields';
import { required } from '../../../../util/validators';
const AzureSubForm = ({ autoPopulateCredential, i18n }) => {
const { setFieldValue } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
const [credentialField, credentialMeta, credentialHelpers] = useField({
name: 'credential',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const handleCredentialUpdate = useCallback(
value => {

View File

@@ -11,12 +11,14 @@ import {
EnabledValueField,
HostFilterField,
} from './SharedFields';
import { required } from '../../../../util/validators';
const CloudFormsSubForm = ({ i18n }) => {
const { setFieldValue } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
const [credentialField, credentialMeta, credentialHelpers] = useField({
name: 'credential',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const handleCredentialUpdate = useCallback(
value => {

View File

@@ -10,12 +10,14 @@ import {
EnabledValueField,
HostFilterField,
} from './SharedFields';
import { required } from '../../../../util/validators';
const GCESubForm = ({ autoPopulateCredential, i18n }) => {
const { setFieldValue } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
const [credentialField, credentialMeta, credentialHelpers] = useField({
name: 'credential',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const handleCredentialUpdate = useCallback(
value => {

View File

@@ -11,12 +11,14 @@ import {
EnabledValueField,
HostFilterField,
} from './SharedFields';
import { required } from '../../../../util/validators';
const OpenStackSubForm = ({ autoPopulateCredential, i18n }) => {
const { setFieldValue } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
const [credentialField, credentialMeta, credentialHelpers] = useField({
name: 'credential',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const handleCredentialUpdate = useCallback(
value => {

View File

@@ -21,7 +21,7 @@ import {
} from './SharedFields';
const SCMSubForm = ({ autoPopulateProject, i18n }) => {
const { setFieldValue } = useFormikContext();
const { setFieldValue, setFieldTouched } = useFormikContext();
const [credentialField] = useField('credential');
const [projectField, projectMeta, projectHelpers] = useField({
name: 'source_project',
@@ -47,16 +47,20 @@ const SCMSubForm = ({ autoPopulateProject, i18n }) => {
useEffect(() => {
if (projectMeta.initialValue) {
fetchSourcePath(projectMeta.initialValue.id);
}
if (sourcePathField.value === '') {
sourcePathHelpers.setValue('/ (project root)');
}
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchSourcePath, projectMeta.initialValue]);
const handleProjectUpdate = useCallback(
value => {
setFieldValue('source_path', '');
setFieldValue('source_project', value);
setFieldValue('source_path', '');
setFieldTouched('source_path', false);
fetchSourcePath(value.id);
},
[fetchSourcePath, setFieldValue]
[fetchSourcePath, setFieldValue, setFieldTouched]
);
const handleCredentialUpdate = useCallback(

View File

@@ -11,12 +11,14 @@ import {
EnabledValueField,
HostFilterField,
} from './SharedFields';
import { required } from '../../../../util/validators';
const SatelliteSubForm = ({ autoPopulateCredential, i18n }) => {
const { setFieldValue } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
const [credentialField, credentialMeta, credentialHelpers] = useField({
name: 'credential',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const handleCredentialUpdate = useCallback(
value => {

View File

@@ -10,12 +10,14 @@ import {
EnabledValueField,
HostFilterField,
} from './SharedFields';
import { required } from '../../../../util/validators';
const TowerSubForm = ({ autoPopulateCredential, i18n }) => {
const { setFieldValue } = useFormikContext();
const [credentialField, credentialMeta, credentialHelpers] = useField(
'credential'
);
const [credentialField, credentialMeta, credentialHelpers] = useField({
name: 'credential',
validate: required(i18n._(t`Select a value for this field`), i18n),
});
const handleCredentialUpdate = useCallback(
value => {

Some files were not shown because too many files have changed in this diff Show More