Compare commits

..

49 Commits

Author SHA1 Message Date
Jeff Bradberry
2ae9156a4a Merge pull request #12587 from ansible/mesh-scaling-backend
Allow for adding external execution nodes via API
2022-08-03 11:09:37 -04:00
Jeff Bradberry
4890c15eeb Update task management to only do things with ready instances 2022-08-02 15:58:43 -04:00
Jeff Bradberry
bc6b8fc4ae Check state when processing receptorctl advertisements
Nodes that show up and were in one of the unready states need to be
transitioned to ready, even if the logic in Instance.is_lost was not
met.
2022-08-02 15:58:20 -04:00
Jeff Bradberry
03c70077f9 Make sure that the health checks handle the state transitions properly
- nodes with states Provisioning, Provisioning Fail, Deprovisioning,
  and Deprovisioning Fail should bypass health checks and should never
  transition due to the existing machinery
- nodes with states Unavailable and Installed can transition to Ready
  if they check out as healthy
- nodes in the Ready state should transition to Unavailable if they
  fail a check
2022-08-02 13:55:35 -04:00
Jeff Bradberry
dab8c3ef55 Update node and link registration to put them in the right state
'Installed' for the nodes, 'Established' for the links.
2022-08-02 13:55:35 -04:00
Jeff Bradberry
d2a6be7ca9 Add the state fields and the peer relationships to the serializers 2022-08-02 13:55:35 -04:00
Jeff Bradberry
170795ab76 Add state fields to Instance and InstanceLink
Also, listener_port to Instance.
2022-08-02 13:55:03 -04:00
Shane McDonald
6446b627ad Merge pull request #12608 from shanemcd/fix-k8s-dev-env
Fix Kubernetes dev environment + update docs
2022-08-01 11:11:45 -04:00
Shane McDonald
fcebd188a6 Fix Kubernetes dev environment + update docs 2022-08-01 10:45:10 -04:00
Shane McDonald
65771b7629 Merge pull request #12562 from shanemcd/auto-install-setuptools-scm
Automatically install setuptools-scm in script called from Makefile
2022-07-31 17:17:26 -04:00
Keith Grant
86a67abbce Merge pull request #12531 from jtmelhorn/devel
[#12478] Change Inventory "Status" column header to "Sync Status"
2022-07-29 15:50:08 -07:00
Keith Grant
d555093325 Fix job output follow mode & scrolling (#12555)
* reworks/fixes follow mode

* reduces batch size for better job output perceived performance

* improves job output scroll button behavior
2022-07-28 15:26:25 -04:00
John Westcott IV
95a099acc5 Adding remove_superuser and remove_system_auditors to the SAML user attribute map (#12522) 2022-07-28 14:38:16 -04:00
John Westcott IV
d1fc2702ec Adding subscriptions module and adding pool_id to license module (#12560) 2022-07-28 12:16:47 -04:00
John Westcott IV
734899228b Updating CONTRIBUTING guide (#12565) 2022-07-27 09:59:09 -04:00
Rick Elrod
87f729c642 [FieldLookupBackend] limit iexact to string fields (#12569)
Change:
- Case-insensitive search only makes sense on strings, so check the
  type of the field we are searching and ensure it is a string field
  (TextField, CharField, or some subclass thereof).

- This prevents a 500 error when a user uses iexact on, e.g., an
  integer field. Now, a 400 Bad Request is returned instead.

Test Plan:
- Added simple unit tests for iexact

Tickets:
- Fixes #9222

Signed-off-by: Rick Elrod <rick@elrod.me>
2022-07-26 12:46:50 -05:00
John Westcott IV
62fc3994fb Modifying SAML adapter to not auto-add default galaxy creds to orgs on login (#12504)
* Modifying SAML adapter to not auto-add default galaxy creds to orgs on login

* Adding test, fixing old tests and moving add_default_galaxy_credential to pipeline
2022-07-25 17:16:22 -03:00
Shane McDonald
0d097964be Automatically install setuptools-scm in script called from Makefile 2022-07-22 12:59:39 -04:00
Christian Adams
9f8b3948e1 Merge pull request #12147 from rooftopcellist/bump-receptor-1.2.3
Bump Receptorctl to 1.2.3
2022-07-21 11:45:27 -04:00
Jessica Steurer
1ce8240192 Merge pull request #12528 from vedaperi/12436-RemoveUpdateOnProjectUpdate
Remove update_on_project_update
2022-07-20 16:14:23 -03:00
Jeff Bradberry
1bcfc8f28e Merge pull request #12544 from jbradberry/awxkit-fix-no-content
Suppress 204 No Content results causing an error during import
2022-07-20 10:48:02 -04:00
vedaperi
71925de902 Enhanced detail component (#12432)
* Enhanced detail component to handle cases with no values, and refactored components that use detail component.

* Add optional chaining operators where necessary to pass test cases

* add test cases to test suites of modified files

Co-authored-by: Veda Periwal <vperiwal@vperiwal-mac.attlocal.net>
2022-07-19 17:17:27 -04:00
Aditya Mulik
54057f1c80 Merge pull request #12467 from adityamulik/localization_scripts
Localization Scripts for AWX UI & API
2022-07-19 16:40:10 -04:00
Aditya Mulik
ae388d943d Merge pull request #12541 from adityamulik/translations_updated_2022-07-18_20_51_59
Pushing updated strings for localization
2022-07-19 16:39:44 -04:00
Alan Rominger
2d310dc4e5 Optimize object creation by getting fewer empty relationships (#12508)
This optimizes the ActivityStreamSerializer by only getting many-to-many
  relationships that are speculatively non-empty
  based on information we have in other fields

We run this every time we create an object as an on_commit action
  so it is expected this will have a major impact on response times for launching jobs
2022-07-19 14:27:51 -04:00
Jeff Bradberry
fe1a767f4f Suppress 204 No Content results causing an error during import 2022-07-19 12:25:24 -04:00
adityamulik
8c6581d80a Pushing updated strings for localization 2022-07-18 20:52:59 -04:00
Jessica Steurer
33e445f4f6 Merge pull request #12489 from kialam/vendor-d3.js-webworker
Remove external script call to D3.js.
2022-07-18 19:10:50 -03:00
Kia Lam
9bcb60d9e0 Remove d3 csp declaration. 2022-07-18 08:57:03 -07:00
Kia Lam
40109d58c7 Host d3 files needed for webworker. 2022-07-18 08:57:02 -07:00
Kia Lam
2ef3f5f9e8 Remove external script call to D3.js. 2022-07-18 08:57:02 -07:00
John Westcott IV
389c4a3180 Adding fields to job_metadata for workflows and approval nodes (#12255) 2022-07-18 16:53:49 +02:00
Justin Melhorn
bee48671cd [#12478] Change Inventory "Status" column header to "Sync Status"
Signed-off-by: Justin Melhorn <jtmelhorn@gmail.com>
2022-07-17 16:38:24 -04:00
Veda Periwal
21f551f48a Remove update_on_project_update from inventory sources form and corresponding files 2022-07-15 11:18:16 -07:00
Alex Corey
cbb019ed09 Merge pull request #12510 from AlexSCorey/11822-JobOutputDocumentation-Overview
Adds Overview of job output with some images to help.
2022-07-15 10:52:47 -04:00
Alex Corey
bf5dfdaba7 Adds Overview of job output with some images to help. 2022-07-15 10:32:41 -04:00
Jessica Steurer
0f7f8af9b8 Merge pull request #12346 from john-westcott-iv/dependabot_fixes
Updating pyjwt per dependabot
2022-07-15 10:42:24 -03:00
Sarabraj Singh
0237402390 Merge pull request #12509 from sarabrajsingh/docs/awx-release-docs-refactoring
buffed docs for awx release and canonical triage responses
2022-07-15 08:21:58 -04:00
Hao Liu
84d7fa882d Merge pull request #12513 from TheRealHaoLiu/fix-workflow-job-template-export
fix WorkflowJobTemplate export
2022-07-14 14:44:58 -04:00
Sarabraj Singh
cd2fae3471 buffed docs for AWX Release and canonical Triage responses 2022-07-14 14:13:18 -04:00
John Westcott IV
8be64145f9 Updating pyjwt per dependabot 2022-07-14 08:35:46 -04:00
djyasin
23d28fb4c8 Merge pull request #12457 from djyasin/feature/bu-metrics-added-forks-in-unified-jobs-table
Added forks to unified jobs table.
2022-07-13 11:33:19 -04:00
Lila
aeffd6f393 Bumped up version number of the collector. 2022-07-13 09:59:41 -04:00
djyasin
ab6b4bad03 Merge branch 'ansible:devel' into devel 2022-07-13 09:53:22 -04:00
Hao Liu
769c253ac2 fix WorkflowJobTemplate export where WorkflowApprovalTemplate is not properly exported
fixes https://github.com/ansible/awx/issues/7946
- added WorkflowApprovalTemplate page type to allow URL registration
- added resources regex that’s associated resource URL with WorkflowApprovalTemplate
- registered the new resource regex with WorkflowApprovalTemplate page type
- modified `DEPENDENT_EXPORT` handling (insisted by @jbradberry)
- added special case handling for WorkflowApprovalTemplate due to its unique nature

unique nature of WorkflowApprovalTemplate
- when exporting WorkflowJobTemplate with approval node the WorkflowJobTemplateNode need to contain a related "create_approval_template" the POST data for "create_approval_template" need to come from the "workflow_approval_template"
- during the export of a WorkflowJobTemplateNode that is an approval node we need to get the data from "workflow_approval_template" and use that to populate the "create_approval_template"

Co-Authored-By: Jeff Bradberry <685957+jbradberry@users.noreply.github.com>
Signed-off-by: Hao Liu <haoli@redhat.com>
2022-07-12 19:48:02 -04:00
Michael Abashian
8031b3d402 Translate contents of Hosts Automated field as a single string (#12480)
* Translate contents of Hosts Automated field as a single string

* Adds unit test case for hiding Hosts automated detail when no value is present
2022-07-12 15:24:33 -04:00
Aditya Mulik
df38650aee Localization Scripts for AWX UI & API 2022-07-08 11:44:56 -04:00
Lila
1e57c84383 Added forks to unified jobs table.
Co-authored-by: sarabrajsingh <singh.sarabraj@gmail.com>
2022-07-01 10:30:48 -04:00
Christian M. Adams
2b0846e8a2 Bump Receptorctl to 1.2.3 2022-05-02 14:41:04 -04:00
121 changed files with 3298 additions and 1282 deletions

View File

@@ -1,5 +1,5 @@
## General
- For the roundup of all the different mailing lists available from AWX, Ansible, and beyond visit: https://docs.ansible.com/ansible/latest/community/communication.html
- For the roundup of all the different mailing lists available from AWX, Ansible, and beyond visit: https://docs.ansible.com/ansible/latest/community/communication.html
- Hello, we think your question is answered in our FAQ. Does this: https://www.ansible.com/products/awx-project/faq cover your question?
- You can find the latest documentation here: https://docs.ansible.com/automation-controller/latest/html/userguide/index.html
@@ -58,10 +58,10 @@ Thank you once again for this and your interest in AWX!
## Common
### Give us more info
- Hello, we'd love to help, but we need a little more information about the problem you're having. Screenshots, log outputs, or any reproducers would be very helpful.
- Hello, we'd love to help, but we need a little more information about the problem you're having. Screenshots, log outputs, or any reproducers would be very helpful.
### Code of Conduct
- Hello. Please keep in mind that Ansible adheres to a Code of Conduct in its community spaces. The spirit of the code of conduct is to be kind, and this is your friendly reminder to be so. Please see the full code of conduct here if you have questions: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html
- Hello. Please keep in mind that Ansible adheres to a Code of Conduct in its community spaces. The spirit of the code of conduct is to be kind, and this is your friendly reminder to be so. Please see the full code of conduct here if you have questions: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html
### EE Contents / Community General
- Hello. The awx-ee contains the collections and dependencies needed for supported AWX features to function. Anything beyond that (like the community.general package) will require you to build your own EE. For information on how to do that, see https://ansible-builder.readthedocs.io/en/stable/ \
@@ -79,31 +79,31 @@ The Ansible Community is looking at building an EE that corresponds to all of th
- Hello, we think your idea is good! Please consider contributing a PR for this following our contributing guidelines: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
### Receptor
- You can find the receptor docs here: https://receptor.readthedocs.io/en/latest/
- You can find the receptor docs here: https://receptor.readthedocs.io/en/latest/
- Hello, your issue seems related to receptor. Could you please open an issue in the receptor repository? https://github.com/ansible/receptor. Thanks!
### Ansible Engine not AWX
- Hello, your question seems to be about Ansible development, not about AWX. Try asking on the Ansible-devel specific mailing list: https://groups.google.com/g/ansible-devel
- Hello, your question seems to be about Ansible development, not about AWX. Try asking on the Ansible-devel specific mailing list: https://groups.google.com/g/ansible-devel
- Hello, your question seems to be about using Ansible, not about AWX. https://groups.google.com/g/ansible-project is the best place to visit for user questions about Ansible. Thanks!
### Ansible Galaxy not AWX
- Hey there. That sounds like an FAQ question. Did this: https://www.ansible.com/products/awx-project/faq cover your question?
### Contributing Guidelines
- AWX: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
- AWX: https://github.com/ansible/awx/blob/devel/CONTRIBUTING.md
- AWX-Operator: https://github.com/ansible/awx-operator/blob/devel/CONTRIBUTING.md
### AWX Release
Subject: Announcing AWX X.Y.z
Subject: Announcing AWX Xa.Ya.za and AWX-Operator Xb.Yb.zb
- Hi all, \
\
We're happy to announce that the next release of AWX, version <X> is now available! \
In addition AWX Operator version <Y> has also been release! \
We're happy to announce that the next release of AWX, version <b>`Xa.Ya.za`</b> is now available! \
In addition AWX Operator version <b>`Xb.Yb.zb`</b> has also been released! \
\
Please see the releases pages for more details: \
AWX: https://github.com/ansible/awx/releases/tag/<X> \
Operator: https://github.com/ansible/awx-operator/releases/tag/<Y> \
AWX: https://github.com/ansible/awx/releases/tag/Xa.Ya.za \
Operator: https://github.com/ansible/awx-operator/releases/tag/Xb.Yb.zb \
\
The AWX team.

View File

@@ -111,9 +111,18 @@ jobs:
repository: ansible/awx-operator
path: awx-operator
- name: Get python version from Makefile
working-directory: awx
run: echo py_version=`make PYTHON_VERSION` >> $GITHUB_ENV
- name: Install python ${{ env.py_version }}
uses: actions/setup-python@v2
with:
python-version: ${{ env.py_version }}
- name: Install playbook dependencies
run: |
python3 -m pip install docker setuptools_scm
python3 -m pip install docker
- name: Build AWX image
working-directory: awx

View File

@@ -65,7 +65,7 @@ jobs:
- name: Install playbook dependencies
run: |
python3 -m pip install docker setuptools_scm
python3 -m pip install docker
- name: Build and stage AWX
working-directory: awx

View File

@@ -19,16 +19,17 @@ Have questions about this document or anything not covered here? Come chat with
- [Purging containers and images](#purging-containers-and-images)
- [Pre commit hooks](#pre-commit-hooks)
- [What should I work on?](#what-should-i-work-on)
- [Translations](#translations)
- [Submitting Pull Requests](#submitting-pull-requests)
- [PR Checks run by Zuul](#pr-checks-run-by-zuul)
- [Reporting Issues](#reporting-issues)
- [Getting Help](#getting-help)
## Things to know prior to submitting code
- All code submissions are done through pull requests against the `devel` branch.
- You must use `git commit --signoff` for any commit to be merged, and agree that usage of --signoff constitutes agreement with the terms of [DCO 1.1](./DCO_1_1.md).
- Take care to make sure no merge commits are in the submission, and use `git rebase` vs `git merge` for this reason.
- If collaborating with someone else on the same branch, consider using `--force-with-lease` instead of `--force`. This will prevent you from accidentally overwriting commits pushed by someone else. For more information, see https://git-scm.com/docs/git-push#git-push---force-with-leaseltrefnamegt
- If collaborating with someone else on the same branch, consider using `--force-with-lease` instead of `--force`. This will prevent you from accidentally overwriting commits pushed by someone else. For more information, see [git push docs](https://git-scm.com/docs/git-push#git-push---force-with-leaseltrefnamegt).
- If submitting a large code change, it's a good idea to join the `#ansible-awx` channel on irc.libera.chat, and talk about what you would like to do or add first. This not only helps everyone know what's going on, it also helps save time and effort, if the community decides some changes are needed.
- We ask all of our community members and contributors to adhere to the [Ansible code of conduct](http://docs.ansible.com/ansible/latest/community/code_of_conduct.html). If you have questions, or need assistance, please reach out to our community team at [codeofconduct@ansible.com](mailto:codeofconduct@ansible.com)
@@ -42,8 +43,7 @@ The AWX development environment workflow and toolchain uses Docker and the docke
Prior to starting the development services, you'll need `docker` and `docker-compose`. On Linux, you can generally find these in your distro's packaging, but you may find that Docker themselves maintain a separate repo that tracks more closely to the latest releases.
For macOS and Windows, we recommend [Docker for Mac](https://www.docker.com/docker-mac) and [Docker for Windows](https://www.docker.com/docker-windows)
respectively.
For macOS and Windows, we recommend [Docker for Mac](https://www.docker.com/docker-mac) and [Docker for Windows](https://www.docker.com/docker-windows) respectively.
For Linux platforms, refer to the following from Docker:
@@ -79,17 +79,13 @@ See the [README.md](./tools/docker-compose/README.md) for docs on how to build t
### Building API Documentation
AWX includes support for building [Swagger/OpenAPI
documentation](https://swagger.io). To build the documentation locally, run:
AWX includes support for building [Swagger/OpenAPI documentation](https://swagger.io). To build the documentation locally, run:
```bash
(container)/awx_devel$ make swagger
```
This will write a file named `swagger.json` that contains the API specification
in OpenAPI format. A variety of online tools are available for translating
this data into more consumable formats (such as HTML). http://editor.swagger.io
is an example of one such service.
This will write a file named `swagger.json` that contains the API specification in OpenAPI format. A variety of online tools are available for translating this data into more consumable formats (such as HTML). http://editor.swagger.io is an example of one such service.
### Accessing the AWX web interface
@@ -115,20 +111,30 @@ While you can use environment variables to skip the pre-commit hooks GitHub will
## What should I work on?
We have a ["good first issue" label](https://github.com/ansible/awx/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) we put on some issues that might be a good starting point for new contributors.
Fixing bugs and updating the documentation are always appreciated, so reviewing the backlog of issues is always a good place to start.
For feature work, take a look at the current [Enhancements](https://github.com/ansible/awx/issues?q=is%3Aissue+is%3Aopen+label%3Atype%3Aenhancement).
If it has someone assigned to it then that person is the person responsible for working the enhancement. If you feel like you could contribute then reach out to that person.
Fixing bugs, adding translations, and updating the documentation are always appreciated, so reviewing the backlog of issues is always a good place to start. For extra information on debugging tools, see [Debugging](./docs/debugging/).
**NOTES**
> Issue assignment will only be done for maintainers of the project. If you decide to work on an issue, please feel free to add a comment in the issue to let others know that you are working on it; but know that we will accept the first pull request from whomever is able to fix an issue. Once your PR is accepted we can add you as an assignee to an issue upon request.
**NOTE**
> If you work in a part of the codebase that is going through active development, your changes may be rejected, or you may be asked to `rebase`. A good idea before starting work is to have a discussion with us in the `#ansible-awx` channel on irc.libera.chat, or on the [mailing list](https://groups.google.com/forum/#!forum/awx-project).
**NOTE**
> If you're planning to develop features or fixes for the UI, please review the [UI Developer doc](./awx/ui/README.md).
### Translations
At this time we do not accept PRs for adding additional language translations as we have an automated process for generating our translations. This is because translations require constant care as new strings are added and changed in the code base. Because of this the .po files are overwritten during every translation release cycle. We also can't support a lot of translations on AWX as its an open source project and each language adds time and cost to maintain. If you would like to see AWX translated into a new language please create an issue and ask others you know to upvote the issue. Our translation team will review the needs of the community and see what they can do around supporting additional language.
If you find an issue with an existing translation, please see the [Reporting Issues](#reporting-issues) section to open an issue and our translation team will work with you on a resolution.
## Submitting Pull Requests
Fixes and Features for AWX will go through the Github pull request process. Submit your pull request (PR) against the `devel` branch.
@@ -152,28 +158,14 @@ We like to keep our commit history clean, and will require resubmission of pull
Sometimes it might take us a while to fully review your PR. We try to keep the `devel` branch in good working order, and so we review requests carefully. Please be patient.
All submitted PRs will have the linter and unit tests run against them via Zuul, and the status reported in the PR.
## PR Checks run by Zuul
Zuul jobs for awx are defined in the [zuul-jobs](https://github.com/ansible/zuul-jobs) repo.
Zuul runs the following checks that must pass:
1. `tox-awx-api-lint`
2. `tox-awx-ui-lint`
3. `tox-awx-api`
4. `tox-awx-ui`
5. `tox-awx-swagger`
Zuul runs the following checks that are non-voting (can not pass but serve to inform PR reviewers):
1. `tox-awx-detect-schema-change`
This check generates the schema and diffs it against a reference copy of the `devel` version of the schema.
Reviewers should inspect the `job-output.txt.gz` related to the check if their is a failure (grep for `diff -u -b` to find beginning of diff).
If the schema change is expected and makes sense in relation to the changes made by the PR, then you are good to go!
If not, the schema changes should be fixed, but this decision must be enforced by reviewers.
When your PR is initially submitted the checks will not be run until a maintainer allows them to be. Once a maintainer has done a quick review of your work the PR will have the linter and unit tests run against them via GitHub Actions, and the status reported in the PR.
## Reporting Issues
We welcome your feedback, and encourage you to file an issue when you run into a problem. But before opening a new issues, we ask that you please view our [Issues guide](./ISSUES.md).
## Getting Help
If you require additional assistance, please reach out to us at `#ansible-awx` on irc.libera.chat, or submit your question to the [mailing list](https://groups.google.com/forum/#!forum/awx-project).
For extra information on debugging tools, see [Debugging](./docs/debugging/).

View File

@@ -232,6 +232,9 @@ class FieldLookupBackend(BaseFilterBackend):
re.compile(value)
except re.error as e:
raise ValueError(e.args[0])
elif new_lookup.endswith('__iexact'):
if not isinstance(field, (CharField, TextField)):
raise ValueError(f'{field.name} is not a text field and cannot be filtered by case-insensitive search')
elif new_lookup.endswith('__search'):
related_model = getattr(field, 'related_model', None)
if not related_model:
@@ -258,8 +261,8 @@ class FieldLookupBackend(BaseFilterBackend):
search_filters = {}
needs_distinct = False
# Can only have two values: 'AND', 'OR'
# If 'AND' is used, an iterm must satisfy all condition to show up in the results.
# If 'OR' is used, an item just need to satisfy one condition to appear in results.
# If 'AND' is used, an item must satisfy all conditions to show up in the results.
# If 'OR' is used, an item just needs to satisfy one condition to appear in results.
search_filter_relation = 'OR'
for key, values in request.query_params.lists():
if key in self.RESERVED_NAMES:

View File

@@ -29,7 +29,6 @@ from django.utils.translation import gettext_lazy as _
from django.utils.encoding import force_str
from django.utils.text import capfirst
from django.utils.timezone import now
from django.utils.functional import cached_property
# Django REST Framework
from rest_framework.exceptions import ValidationError, PermissionDenied
@@ -4754,7 +4753,7 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria
class InstanceLinkSerializer(BaseSerializer):
class Meta:
model = InstanceLink
fields = ('source', 'target')
fields = ('source', 'target', 'link_state')
source = serializers.SlugRelatedField(slug_field="hostname", read_only=True)
target = serializers.SlugRelatedField(slug_field="hostname", read_only=True)
@@ -4763,31 +4762,25 @@ class InstanceLinkSerializer(BaseSerializer):
class InstanceNodeSerializer(BaseSerializer):
class Meta:
model = Instance
fields = ('id', 'hostname', 'node_type', 'node_state')
node_state = serializers.SerializerMethodField()
def get_node_state(self, obj):
if not obj.enabled:
return "disabled"
return "error" if obj.errors else "healthy"
fields = ('id', 'hostname', 'node_type', 'node_state', 'enabled')
class InstanceSerializer(BaseSerializer):
consumed_capacity = serializers.SerializerMethodField()
percent_capacity_remaining = serializers.SerializerMethodField()
jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that ' 'are targeted for this instance'), read_only=True)
jobs_running = serializers.IntegerField(help_text=_('Count of jobs in the running or waiting state that are targeted for this instance'), read_only=True)
jobs_total = serializers.IntegerField(help_text=_('Count of all jobs that target this instance'), read_only=True)
class Meta:
model = Instance
read_only_fields = ('uuid', 'hostname', 'version', 'node_type')
read_only_fields = ('uuid', 'hostname', 'version', 'node_type', 'node_state')
fields = (
"id",
"type",
"url",
"related",
"summary_fields",
"uuid",
"hostname",
"created",
@@ -4809,6 +4802,7 @@ class InstanceSerializer(BaseSerializer):
"enabled",
"managed_by_policy",
"node_type",
"node_state",
)
def get_related(self, obj):
@@ -4820,6 +4814,14 @@ class InstanceSerializer(BaseSerializer):
res['health_check'] = self.reverse('api:instance_health_check', kwargs={'pk': obj.pk})
return res
def get_summary_fields(self, obj):
summary = super().get_summary_fields(obj)
if self.is_detail_view:
summary['links'] = InstanceLinkSerializer(InstanceLink.objects.select_related('target', 'source').filter(source=obj), many=True).data
return summary
def get_consumed_capacity(self, obj):
return obj.consumed_capacity
@@ -5008,8 +5010,7 @@ class ActivityStreamSerializer(BaseSerializer):
object_association = serializers.SerializerMethodField(help_text=_("When present, shows the field name of the role or relationship that changed."))
object_type = serializers.SerializerMethodField(help_text=_("When present, shows the model on which the role or relationship was defined."))
@cached_property
def _local_summarizable_fk_fields(self):
def _local_summarizable_fk_fields(self, obj):
summary_dict = copy.copy(SUMMARIZABLE_FK_FIELDS)
# Special requests
summary_dict['group'] = summary_dict['group'] + ('inventory_id',)
@@ -5029,7 +5030,13 @@ class ActivityStreamSerializer(BaseSerializer):
('workflow_approval', ('id', 'name', 'unified_job_id')),
('instance', ('id', 'hostname')),
]
return field_list
# Optimization - do not attempt to summarize all fields, pair down to only relations that exist
if not obj:
return field_list
existing_association_types = [obj.object1, obj.object2]
if 'user' in existing_association_types:
existing_association_types.append('role')
return [entry for entry in field_list if entry[0] in existing_association_types]
class Meta:
model = ActivityStream
@@ -5113,7 +5120,7 @@ class ActivityStreamSerializer(BaseSerializer):
data = {}
if obj.actor is not None:
data['actor'] = self.reverse('api:user_detail', kwargs={'pk': obj.actor.pk})
for fk, __ in self._local_summarizable_fk_fields:
for fk, __ in self._local_summarizable_fk_fields(obj):
if not hasattr(obj, fk):
continue
m2m_list = self._get_related_objects(obj, fk)
@@ -5170,7 +5177,7 @@ class ActivityStreamSerializer(BaseSerializer):
def get_summary_fields(self, obj):
summary_fields = OrderedDict()
for fk, related_fields in self._local_summarizable_fk_fields:
for fk, related_fields in self._local_summarizable_fk_fields(obj):
try:
if not hasattr(obj, fk):
continue

View File

@@ -440,6 +440,7 @@ class InstanceHealthCheck(GenericAPIView):
def post(self, request, *args, **kwargs):
obj = self.get_object()
# Note: hop nodes are already excluded by the get_queryset method
if obj.node_type == 'execution':
from awx.main.tasks.system import execution_node_health_check

View File

@@ -1440,7 +1440,7 @@ msgstr "指定した認証情報は無効 (HTTP 401) です。"
#: awx/api/views/root.py:193 awx/api/views/root.py:234
msgid "Unable to connect to proxy server."
msgstr "プロキシサーバーに接続できません。"
msgstr "プロキシサーバーに接続できません。"
#: awx/api/views/root.py:195 awx/api/views/root.py:236
msgid "Could not connect to subscription service."
@@ -1976,7 +1976,7 @@ msgstr "リモートホスト名または IP を判別するために検索す
#: awx/main/conf.py:85
msgid "Proxy IP Allowed List"
msgstr "プロキシ IP 許可リスト"
msgstr "プロキシ IP 許可リスト"
#: awx/main/conf.py:87
msgid ""
@@ -2198,7 +2198,7 @@ msgid ""
"Follow symbolic links when scanning for playbooks. Be aware that setting "
"this to True can lead to infinite recursion if a link points to a parent "
"directory of itself."
msgstr "Playbook スキャンするときは、シンボリックリンクをたどってください。リンクがそれ自体の親ディレクトリーをしている場合は、こを True に定すると無限再帰が発生する可能性があることに注意してください。"
msgstr "Playbook スキャン時にシンボリックリンクをたどります。リンクが親ディレクトリーを参照している場合は、この設定を True に定すると無限再帰が発生する可能性があります。"
#: awx/main/conf.py:337
msgid "Ignore Ansible Galaxy SSL Certificate Verification"
@@ -2499,7 +2499,7 @@ msgstr "Insights for Ansible Automation Platform の最終収集日。"
msgid ""
"Last gathered entries for expensive collectors for Insights for Ansible "
"Automation Platform."
msgstr "Insights for Ansible Automation Platform の高価なコレクター最後に収集されたエントリー"
msgstr "Insights for Ansible Automation Platform でコストがかかっているコレクターに関して最後に収集されたエントリー"
#: awx/main/conf.py:686
msgid "Insights for Ansible Automation Platform Gather Interval"
@@ -3692,7 +3692,7 @@ msgstr "タスクの開始"
#: awx/main/models/events.py:189
msgid "Variables Prompted"
msgstr "変数のプロモート"
msgstr "提示される変数"
#: awx/main/models/events.py:190
msgid "Gathering Facts"
@@ -3741,15 +3741,15 @@ msgstr "エラー"
#: awx/main/models/execution_environments.py:17
msgid "Always pull container before running."
msgstr "実行前に必ずコンテナーをプルしてください。"
msgstr "実行前に必ずコンテナーをプルする"
#: awx/main/models/execution_environments.py:18
msgid "Only pull the image if not present before running."
msgstr "実行する前に、存在しない場合のみイメージをプルしてください。"
msgstr "イメージが存在しない場合のみ実行前にプルする"
#: awx/main/models/execution_environments.py:19
msgid "Never pull container before running."
msgstr "実行前にコンテナーをプルしないでください。"
msgstr "実行前にコンテナーをプルしない"
#: awx/main/models/execution_environments.py:29
msgid ""
@@ -5228,7 +5228,7 @@ msgid ""
"SSL) or \"ldaps://ldap.example.com:636\" (SSL). Multiple LDAP servers may be "
"specified by separating with spaces or commas. LDAP authentication is "
"disabled if this parameter is empty."
msgstr "\"ldap://ldap.example.com:389\" (非 SSL) または \"ldaps://ldap.example.com:636\" (SSL) などの LDAP サーバーに接続する URI です。複数の LDAP サーバーをスペースまたはンマで区切って指定できます。LDAP 認証は、このパラメーターが空の場合は無効になります。"
msgstr "\"ldap://ldap.example.com:389\" (非 SSL) または \"ldaps://ldap.example.com:636\" (SSL) などの LDAP サーバーに接続する URI です。複数の LDAP サーバーをスペースまたはンマで区切って指定できます。LDAP 認証は、このパラメーターが空の場合は無効になります。"
#: awx/sso/conf.py:170 awx/sso/conf.py:187 awx/sso/conf.py:198
#: awx/sso/conf.py:209 awx/sso/conf.py:226 awx/sso/conf.py:244
@@ -6236,4 +6236,5 @@ msgstr "%s が現在アップグレード中です。"
#: awx/ui/urls.py:24
msgid "This page will refresh when complete."
msgstr "このページは完了すると更新されます。"
msgstr "このページは完了すると更新されます。"

View File

@@ -956,7 +956,7 @@ msgstr "인스턴스 그룹의 인스턴스"
#: awx/api/views/__init__.py:450
msgid "Schedules"
msgstr "일정"
msgstr "스케줄"
#: awx/api/views/__init__.py:464
msgid "Schedule Recurrence Rule Preview"
@@ -3261,7 +3261,7 @@ msgstr "JSON 또는 YAML 구문을 사용하여 인젝터를 입력합니다.
#: awx/main/models/credential/__init__.py:412
#, python-format
msgid "adding %s credential type"
msgstr "인증 정보 유형 %s 추가 중"
msgstr "인증 정보 유형 %s 추가 중"
#: awx/main/models/credential/__init__.py:590
#: awx/main/models/credential/__init__.py:672
@@ -6236,4 +6236,5 @@ msgstr "%s 현재 업그레이드 중입니다."
#: awx/ui/urls.py:24
msgid "This page will refresh when complete."
msgstr "완료되면 이 페이지가 새로 고침됩니다."
msgstr "완료되면 이 페이지가 새로 고침됩니다."

View File

@@ -348,7 +348,7 @@ msgstr "SCM track_submodules 只能用于 git 项目。"
msgid ""
"Only Container Registry credentials can be associated with an Execution "
"Environment"
msgstr "只有容器 registry 凭证可以与执行环境关联"
msgstr "只有容器注册表凭证可以与执行环境关联"
#: awx/api/serializers.py:1440
msgid "Cannot change the organization of an execution environment"
@@ -629,7 +629,7 @@ msgstr "不支持在不替换的情况下在启动时删除 {} 凭证。提供
#: awx/api/serializers.py:4338
msgid "The inventory associated with this Workflow is being deleted."
msgstr "与此 Workflow 关联的清单将被删除。"
msgstr "与此工作流关联的清单将被删除。"
#: awx/api/serializers.py:4405
msgid "Message type '{}' invalid, must be either 'message' or 'body'"
@@ -3229,7 +3229,7 @@ msgstr "云"
#: awx/main/models/credential/__init__.py:336
#: awx/main/models/credential/__init__.py:1113
msgid "Container Registry"
msgstr "容器 Registry"
msgstr "容器注册表"
#: awx/main/models/credential/__init__.py:337
msgid "Personal Access Token"
@@ -3560,7 +3560,7 @@ msgstr "身份验证 URL"
#: awx/main/models/credential/__init__.py:1120
msgid "Authentication endpoint for the container registry."
msgstr "容器 registry 的身份验证端点。"
msgstr "容器注册表的身份验证端点。"
#: awx/main/models/credential/__init__.py:1130
msgid "Password or Token"
@@ -3764,7 +3764,7 @@ msgstr "镜像位置"
msgid ""
"The full image location, including the container registry, image name, and "
"version tag."
msgstr "完整镜像位置,包括容器 registry、镜像名称和版本标签。"
msgstr "完整镜像位置,包括容器注册表、镜像名称和版本标签。"
#: awx/main/models/execution_environments.py:51
msgid "Pull image before running?"
@@ -6238,4 +6238,5 @@ msgstr "%s 当前正在升级。"
#: awx/ui/urls.py:24
msgid "This page will refresh when complete."
msgstr "完成后,此页面会刷新。"
msgstr "完成后,此页面会刷新。"

View File

@@ -396,7 +396,7 @@ def events_table_partitioned_modified(since, full_path, until, **kwargs):
return _events_table(since, full_path, until, 'main_jobevent', 'modified', project_job_created=True, **kwargs)
@register('unified_jobs_table', '1.3', format='csv', description=_('Data on jobs run'), expensive=four_hour_slicing)
@register('unified_jobs_table', '1.4', format='csv', description=_('Data on jobs run'), expensive=four_hour_slicing)
def unified_jobs_table(since, full_path, until, **kwargs):
unified_job_query = '''COPY (SELECT main_unifiedjob.id,
main_unifiedjob.polymorphic_ctype_id,
@@ -422,7 +422,8 @@ def unified_jobs_table(since, full_path, until, **kwargs):
main_unifiedjob.job_explanation,
main_unifiedjob.instance_group_id,
main_unifiedjob.installed_collections,
main_unifiedjob.ansible_version
main_unifiedjob.ansible_version,
main_job.forks
FROM main_unifiedjob
JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id
LEFT JOIN main_job ON main_unifiedjob.id = main_job.unifiedjob_ptr_id

View File

@@ -27,7 +27,9 @@ class Command(BaseCommand):
)
def handle(self, **options):
# provides a mapping of hostname to Instance objects
nodes = Instance.objects.in_bulk(field_name='hostname')
if options['source'] not in nodes:
raise CommandError(f"Host {options['source']} is not a registered instance.")
if not (options['peers'] or options['disconnect'] or options['exact'] is not None):
@@ -57,7 +59,9 @@ class Command(BaseCommand):
results = 0
for target in options['peers']:
_, created = InstanceLink.objects.get_or_create(source=nodes[options['source']], target=nodes[target])
_, created = InstanceLink.objects.update_or_create(
source=nodes[options['source']], target=nodes[target], defaults={'link_state': InstanceLink.States.ESTABLISHED}
)
if created:
results += 1
@@ -80,7 +84,9 @@ class Command(BaseCommand):
links = set(InstanceLink.objects.filter(source=nodes[options['source']]).values_list('target__hostname', flat=True))
removals, _ = InstanceLink.objects.filter(source=nodes[options['source']], target__hostname__in=links - peers).delete()
for target in peers - links:
_, created = InstanceLink.objects.get_or_create(source=nodes[options['source']], target=nodes[target])
_, created = InstanceLink.objects.update_or_create(
source=nodes[options['source']], target=nodes[target], defaults={'link_state': InstanceLink.States.ESTABLISHED}
)
if created:
additions += 1

View File

@@ -129,10 +129,13 @@ class InstanceManager(models.Manager):
# if instance was not retrieved by uuid and hostname was, use the hostname
instance = self.filter(hostname=hostname)
from awx.main.models import Instance
# Return existing instance
if instance.exists():
instance = instance.first() # in the unusual occasion that there is more than one, only get one
update_fields = []
instance.node_state = Instance.States.INSTALLED # Wait for it to show up on the mesh
update_fields = ['node_state']
# if instance was retrieved by uuid and hostname has changed, update hostname
if instance.hostname != hostname:
logger.warning("passed in hostname {0} is different from the original hostname {1}, updating to {0}".format(hostname, instance.hostname))
@@ -141,6 +144,7 @@ class InstanceManager(models.Manager):
# if any other fields are to be updated
if instance.ip_address != ip_address:
instance.ip_address = ip_address
update_fields.append('ip_address')
if instance.node_type != node_type:
instance.node_type = node_type
update_fields.append('node_type')
@@ -151,12 +155,12 @@ class InstanceManager(models.Manager):
return (False, instance)
# Create new instance, and fill in default values
create_defaults = dict(capacity=0)
create_defaults = {'node_state': Instance.States.INSTALLED, 'capacity': 0}
if defaults is not None:
create_defaults.update(defaults)
uuid_option = {}
if uuid is not None:
uuid_option = dict(uuid=uuid)
uuid_option = {'uuid': uuid}
if node_type == 'execution' and 'version' not in create_defaults:
create_defaults['version'] = RECEPTOR_PENDING
instance = self.create(hostname=hostname, ip_address=ip_address, node_type=node_type, **create_defaults, **uuid_option)

View File

@@ -0,0 +1,79 @@
# Generated by Django 3.2.13 on 2022-08-02 17:53
import django.core.validators
from django.db import migrations, models
def forwards(apps, schema_editor):
# All existing InstanceLink objects need to be in the state
# 'Established', which is the default, so nothing needs to be done
# for that.
Instance = apps.get_model('main', 'Instance')
for instance in Instance.objects.all():
instance.node_state = 'ready' if not instance.errors else 'unavailable'
instance.save(update_fields=['node_state'])
class Migration(migrations.Migration):
dependencies = [
('main', '0164_remove_inventorysource_update_on_project_update'),
]
operations = [
migrations.AddField(
model_name='instance',
name='listener_port',
field=models.PositiveIntegerField(
blank=True,
default=27199,
help_text='Port that Receptor will listen for incoming connections on.',
validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)],
),
),
migrations.AddField(
model_name='instance',
name='node_state',
field=models.CharField(
choices=[
('provisioning', 'Provisioning'),
('provision-fail', 'Provisioning Failure'),
('installed', 'Installed'),
('ready', 'Ready'),
('unavailable', 'Unavailable'),
('deprovisioning', 'De-provisioning'),
('deprovision-fail', 'De-provisioning Failure'),
],
default='ready',
help_text='Indicates the current life cycle stage of this instance.',
max_length=16,
),
),
migrations.AddField(
model_name='instancelink',
name='link_state',
field=models.CharField(
choices=[('adding', 'Adding'), ('established', 'Established'), ('removing', 'Removing')],
default='established',
help_text='Indicates the current life cycle stage of this peer link.',
max_length=16,
),
),
migrations.AlterField(
model_name='instance',
name='node_type',
field=models.CharField(
choices=[
('control', 'Control plane node'),
('execution', 'Execution plane node'),
('hybrid', 'Controller and execution'),
('hop', 'Message-passing node, no execution capability'),
],
default='hybrid',
help_text='Role that this node plays in the mesh.',
max_length=16,
),
),
migrations.RunPython(forwards, reverse_code=migrations.RunPython.noop),
]

View File

@@ -5,7 +5,7 @@ from decimal import Decimal
import logging
import os
from django.core.validators import MinValueValidator
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models, connection
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
@@ -58,6 +58,15 @@ class InstanceLink(BaseModel):
source = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='+')
target = models.ForeignKey('Instance', on_delete=models.CASCADE, related_name='reverse_peers')
class States(models.TextChoices):
ADDING = 'adding', _('Adding')
ESTABLISHED = 'established', _('Established')
REMOVING = 'removing', _('Removing')
link_state = models.CharField(
choices=States.choices, default=States.ESTABLISHED, max_length=16, help_text=_("Indicates the current life cycle stage of this peer link.")
)
class Meta:
unique_together = ('source', 'target')
@@ -126,13 +135,33 @@ class Instance(HasPolicyEditsMixin, BaseModel):
default=0,
editable=False,
)
NODE_TYPE_CHOICES = [
("control", "Control plane node"),
("execution", "Execution plane node"),
("hybrid", "Controller and execution"),
("hop", "Message-passing node, no execution capability"),
]
node_type = models.CharField(default='hybrid', choices=NODE_TYPE_CHOICES, max_length=16)
class Types(models.TextChoices):
CONTROL = 'control', _("Control plane node")
EXECUTION = 'execution', _("Execution plane node")
HYBRID = 'hybrid', _("Controller and execution")
HOP = 'hop', _("Message-passing node, no execution capability")
node_type = models.CharField(default=Types.HYBRID, choices=Types.choices, max_length=16, help_text=_("Role that this node plays in the mesh."))
class States(models.TextChoices):
PROVISIONING = 'provisioning', _('Provisioning')
PROVISION_FAIL = 'provision-fail', _('Provisioning Failure')
INSTALLED = 'installed', _('Installed')
READY = 'ready', _('Ready')
UNAVAILABLE = 'unavailable', _('Unavailable')
DEPROVISIONING = 'deprovisioning', _('De-provisioning')
DEPROVISION_FAIL = 'deprovision-fail', _('De-provisioning Failure')
node_state = models.CharField(
choices=States.choices, default=States.READY, max_length=16, help_text=_("Indicates the current life cycle stage of this instance.")
)
listener_port = models.PositiveIntegerField(
blank=True,
default=27199,
validators=[MinValueValidator(1), MaxValueValidator(65535)],
help_text=_("Port that Receptor will listen for incoming connections on."),
)
peers = models.ManyToManyField('self', symmetrical=False, through=InstanceLink, through_fields=('source', 'target'))
@@ -209,15 +238,18 @@ class Instance(HasPolicyEditsMixin, BaseModel):
return self.last_seen < ref_time - timedelta(seconds=grace_period)
def mark_offline(self, update_last_seen=False, perform_save=True, errors=''):
if self.cpu_capacity == 0 and self.mem_capacity == 0 and self.capacity == 0 and self.errors == errors and (not update_last_seen):
if self.node_state not in (Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED):
return
if self.node_state == Instance.States.UNAVAILABLE and self.errors == errors and (not update_last_seen):
return
self.node_state = Instance.States.UNAVAILABLE
self.cpu_capacity = self.mem_capacity = self.capacity = 0
self.errors = errors
if update_last_seen:
self.last_seen = now()
if perform_save:
update_fields = ['capacity', 'cpu_capacity', 'mem_capacity', 'errors']
update_fields = ['node_state', 'capacity', 'cpu_capacity', 'mem_capacity', 'errors']
if update_last_seen:
update_fields += ['last_seen']
self.save(update_fields=update_fields)
@@ -274,6 +306,9 @@ class Instance(HasPolicyEditsMixin, BaseModel):
if not errors:
self.refresh_capacity_fields()
self.errors = ''
if self.node_state in (Instance.States.UNAVAILABLE, Instance.States.INSTALLED):
self.node_state = Instance.States.READY
update_fields.append('node_state')
else:
self.mark_offline(perform_save=False, errors=errors)
update_fields.extend(['cpu_capacity', 'mem_capacity', 'capacity'])
@@ -292,7 +327,7 @@ class Instance(HasPolicyEditsMixin, BaseModel):
# playbook event data; we should consider this a zero capacity event
redis.Redis.from_url(settings.BROKER_URL).ping()
except redis.ConnectionError:
errors = _('Failed to connect ot Redis')
errors = _('Failed to connect to Redis')
self.save_health_data(awx_application_version, get_cpu_count(), get_mem_in_bytes(), update_last_seen=True, errors=errors)

View File

@@ -408,6 +408,7 @@ class JobNotificationMixin(object):
'inventory': 'Stub Inventory',
'id': 42,
'hosts': {},
'extra_vars': {},
'friendly_name': 'Job',
'finished': False,
'credential': 'Stub credential',

View File

@@ -114,13 +114,6 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi
def _get_related_jobs(self):
return UnifiedJob.objects.non_polymorphic().filter(organization=self)
def create_default_galaxy_credential(self):
from awx.main.models import Credential
public_galaxy_credential = Credential.objects.filter(managed=True, name='Ansible Galaxy').first()
if public_galaxy_credential is not None and public_galaxy_credential not in self.galaxy_credentials.all():
self.galaxy_credentials.add(public_galaxy_credential)
class OrganizationGalaxyCredentialMembership(models.Model):

View File

@@ -659,6 +659,13 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio
node_job_description = 'job #{0}, "{1}", which finished with status {2}.'.format(node.job.id, node.job.name, node.job.status)
str_arr.append("- node #{0} spawns {1}".format(node.id, node_job_description))
result['body'] = '\n'.join(str_arr)
result.update(
dict(
inventory=self.inventory.name if self.inventory else None,
limit=self.limit,
extra_vars=self.display_extra_vars(),
)
)
return result
@property
@@ -906,3 +913,12 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin):
@property
def workflow_job(self):
return self.unified_job_node.workflow_job
def notification_data(self):
result = super(WorkflowApproval, self).notification_data()
result.update(
dict(
extra_vars=self.workflow_job.display_extra_vars(),
)
)
return result

View File

@@ -38,7 +38,9 @@ class TaskManagerInstances:
self.instances_by_hostname = dict()
if instances is None:
instances = (
Instance.objects.filter(hostname__isnull=False, enabled=True).exclude(node_type='hop').only('node_type', 'capacity', 'hostname', 'enabled')
Instance.objects.filter(hostname__isnull=False, node_state=Instance.States.READY, enabled=True)
.exclude(node_type='hop')
.only('node_type', 'node_state', 'capacity', 'hostname', 'enabled')
)
for instance in instances:
self.instances_by_hostname[instance.hostname] = TaskManagerInstance(instance)

View File

@@ -409,7 +409,7 @@ def emit_activity_stream_change(instance):
from awx.api.serializers import ActivityStreamSerializer
actor = None
if instance.actor:
if instance.actor_id:
actor = instance.actor.username
summary_fields = ActivityStreamSerializer(instance).get_summary_fields(instance)
analytics_logger.info(

View File

@@ -114,7 +114,7 @@ def inform_cluster_of_shutdown():
try:
this_inst = Instance.objects.get(hostname=settings.CLUSTER_HOST_ID)
this_inst.mark_offline(update_last_seen=True, errors=_('Instance received normal shutdown signal'))
logger.warning('Normal shutdown signal for instance {}, ' 'removed self from capacity pool.'.format(this_inst.hostname))
logger.warning('Normal shutdown signal for instance {}, removed self from capacity pool.'.format(this_inst.hostname))
except Exception:
logger.exception('Encountered problem with normal shutdown signal.')
@@ -341,9 +341,13 @@ def _cleanup_images_and_files(**kwargs):
logger.info(f'Performed local cleanup with kwargs {kwargs}, output:\n{stdout}')
# if we are the first instance alphabetically, then run cleanup on execution nodes
checker_instance = Instance.objects.filter(node_type__in=['hybrid', 'control'], enabled=True, capacity__gt=0).order_by('-hostname').first()
checker_instance = (
Instance.objects.filter(node_type__in=['hybrid', 'control'], node_state=Instance.States.READY, enabled=True, capacity__gt=0)
.order_by('-hostname')
.first()
)
if checker_instance and this_inst.hostname == checker_instance.hostname:
for inst in Instance.objects.filter(node_type='execution', enabled=True, capacity__gt=0):
for inst in Instance.objects.filter(node_type='execution', node_state=Instance.States.READY, enabled=True, capacity__gt=0):
runner_cleanup_kwargs = inst.get_cleanup_task_kwargs(**kwargs)
if not runner_cleanup_kwargs:
continue
@@ -399,6 +403,9 @@ def execution_node_health_check(node):
if instance.node_type != 'execution':
raise RuntimeError(f'Execution node health check ran against {instance.node_type} node {instance.hostname}')
if instance.node_state not in (Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED):
raise RuntimeError(f"Execution node health check ran against node {instance.hostname} in state {instance.node_state}")
data = worker_info(node)
prior_capacity = instance.capacity
@@ -432,6 +439,7 @@ def inspect_execution_nodes(instance_list):
nowtime = now()
workers = mesh_status['Advertisements']
for ad in workers:
hostname = ad['NodeID']
@@ -445,9 +453,7 @@ def inspect_execution_nodes(instance_list):
if instance.node_type in ('control', 'hybrid'):
continue
was_lost = instance.is_lost(ref_time=nowtime)
last_seen = parse_date(ad['Time'])
if instance.last_seen and instance.last_seen >= last_seen:
continue
instance.last_seen = last_seen
@@ -455,12 +461,12 @@ def inspect_execution_nodes(instance_list):
# Only execution nodes should be dealt with by execution_node_health_check
if instance.node_type == 'hop':
if was_lost and (not instance.is_lost(ref_time=nowtime)):
if instance.node_state in (Instance.States.UNAVAILABLE, Instance.States.INSTALLED):
logger.warning(f'Hop node {hostname}, has rejoined the receptor mesh')
instance.save_health_data(errors='')
continue
if was_lost:
if instance.node_state in (Instance.States.UNAVAILABLE, Instance.States.INSTALLED):
# if the instance *was* lost, but has appeared again,
# attempt to re-establish the initial capacity and version
# check
@@ -479,7 +485,7 @@ def inspect_execution_nodes(instance_list):
def cluster_node_heartbeat():
logger.debug("Cluster node heartbeat task.")
nowtime = now()
instance_list = list(Instance.objects.all())
instance_list = list(Instance.objects.filter(node_state__in=(Instance.States.READY, Instance.States.UNAVAILABLE, Instance.States.INSTALLED)))
this_inst = None
lost_instances = []
@@ -530,9 +536,9 @@ def cluster_node_heartbeat():
try:
if settings.AWX_AUTO_DEPROVISION_INSTANCES:
deprovision_hostname = other_inst.hostname
other_inst.delete()
other_inst.delete() # FIXME: what about associated inbound links?
logger.info("Host {} Automatically Deprovisioned.".format(deprovision_hostname))
elif other_inst.capacity != 0 or (not other_inst.errors):
elif other_inst.node_state == Instance.States.READY:
other_inst.mark_offline(errors=_('Another cluster node has determined this instance to be unresponsive'))
logger.error("Host {} last checked in at {}, marked as lost.".format(other_inst.hostname, other_inst.last_seen))

View File

@@ -20,7 +20,7 @@ def test_activity_stream_related():
"""
serializer_related = set(
ActivityStream._meta.get_field(field_name).related_model
for field_name, stuff in ActivityStreamSerializer()._local_summarizable_fk_fields
for field_name, stuff in ActivityStreamSerializer()._local_summarizable_fk_fields(None)
if hasattr(ActivityStream, field_name)
)

View File

@@ -79,6 +79,19 @@ def test_invalid_field():
assert 'is not an allowed field name. Must be ascii encodable.' in str(excinfo.value)
def test_valid_iexact():
field_lookup = FieldLookupBackend()
value, new_lookup, _ = field_lookup.value_to_python(JobTemplate, 'project__name__iexact', 'foo')
assert 'foo' in value
def test_invalid_iexact():
field_lookup = FieldLookupBackend()
with pytest.raises(ValueError) as excinfo:
field_lookup.value_to_python(Job, 'id__iexact', '1')
assert 'is not a text field and cannot be filtered by case-insensitive search' in str(excinfo.value)
@pytest.mark.parametrize('lookup_suffix', ['', 'contains', 'startswith', 'in'])
@pytest.mark.parametrize('password_field', Credential.PASSWORD_FIELDS)
def test_filter_on_password_field(password_field, lookup_suffix):

View File

@@ -1537,9 +1537,11 @@ register(
('is_superuser_attr', 'saml_attr'),
('is_superuser_value', 'value'),
('is_superuser_role', 'saml_role'),
('remove_superusers', True),
('is_system_auditor_attr', 'saml_attr'),
('is_system_auditor_value', 'value'),
('is_system_auditor_role', 'saml_role'),
('remove_system_auditors', True),
],
)

View File

@@ -743,8 +743,10 @@ class SAMLUserFlagsAttrField(HybridDictField):
is_superuser_attr = fields.CharField(required=False, allow_null=True)
is_superuser_value = fields.CharField(required=False, allow_null=True)
is_superuser_role = fields.CharField(required=False, allow_null=True)
remove_superusers = fields.BooleanField(required=False, allow_null=True)
is_system_auditor_attr = fields.CharField(required=False, allow_null=True)
is_system_auditor_value = fields.CharField(required=False, allow_null=True)
is_system_auditor_role = fields.CharField(required=False, allow_null=True)
remove_system_auditors = fields.BooleanField(required=False, allow_null=True)
child = _Forbidden()

View File

@@ -77,6 +77,21 @@ def _update_m2m_from_expression(user, related, expr, remove=True):
related.remove(user)
def get_or_create_with_default_galaxy_cred(**kwargs):
from awx.main.models import Organization, Credential
(org, org_created) = Organization.objects.get_or_create(**kwargs)
if org_created:
logger.debug("Created org {} (id {}) from {}".format(org.name, org.id, kwargs))
public_galaxy_credential = Credential.objects.filter(managed=True, name='Ansible Galaxy').first()
if public_galaxy_credential is not None:
org.galaxy_credentials.add(public_galaxy_credential)
logger.debug("Added default Ansible Galaxy credential to org")
else:
logger.debug("Could not find default Ansible Galaxy credential to add to org")
return org
def _update_org_from_attr(user, related, attr, remove, remove_admins, remove_auditors, backend):
from awx.main.models import Organization
from django.conf import settings
@@ -94,8 +109,7 @@ def _update_org_from_attr(user, related, attr, remove, remove_admins, remove_aud
organization_name = org_name
except Exception:
organization_name = org_name
org = Organization.objects.get_or_create(name=organization_name)[0]
org.create_default_galaxy_credential()
org = get_or_create_with_default_galaxy_cred(name=organization_name)
else:
org = Organization.objects.get(name=org_name)
except ObjectDoesNotExist:
@@ -121,7 +135,6 @@ def update_user_orgs(backend, details, user=None, *args, **kwargs):
"""
if not user:
return
from awx.main.models import Organization
org_map = backend.setting('ORGANIZATION_MAP') or {}
for org_name, org_opts in org_map.items():
@@ -130,8 +143,7 @@ def update_user_orgs(backend, details, user=None, *args, **kwargs):
organization_name = organization_alias
else:
organization_name = org_name
org = Organization.objects.get_or_create(name=organization_name)[0]
org.create_default_galaxy_credential()
org = get_or_create_with_default_galaxy_cred(name=organization_name)
# Update org admins from expression(s).
remove = bool(org_opts.get('remove', True))
@@ -152,15 +164,14 @@ def update_user_teams(backend, details, user=None, *args, **kwargs):
"""
if not user:
return
from awx.main.models import Organization, Team
from awx.main.models import Team
team_map = backend.setting('TEAM_MAP') or {}
for team_name, team_opts in team_map.items():
# Get or create the org to update.
if 'organization' not in team_opts:
continue
org = Organization.objects.get_or_create(name=team_opts['organization'])[0]
org.create_default_galaxy_credential()
org = get_or_create_with_default_galaxy_cred(name=team_opts['organization'])
# Update team members from expression(s).
team = Team.objects.get_or_create(name=team_name, organization=org)[0]
@@ -216,8 +227,7 @@ def update_user_teams_by_saml_attr(backend, details, user=None, *args, **kwargs)
try:
if settings.SAML_AUTO_CREATE_OBJECTS:
org = Organization.objects.get_or_create(name=organization_name)[0]
org.create_default_galaxy_credential()
org = get_or_create_with_default_galaxy_cred(name=organization_name)
else:
org = Organization.objects.get(name=organization_name)
except ObjectDoesNotExist:
@@ -245,6 +255,7 @@ def _check_flag(user, flag, attributes, user_flags_settings):
is_role_key = "is_%s_role" % (flag)
is_attr_key = "is_%s_attr" % (flag)
is_value_key = "is_%s_value" % (flag)
remove_setting = "remove_%ss" % (flag)
# Check to see if we are respecting a role and, if so, does our user have that role?
role_setting = user_flags_settings.get(is_role_key, None)
@@ -276,7 +287,7 @@ def _check_flag(user, flag, attributes, user_flags_settings):
# if they don't match make sure that new_flag is false
else:
logger.debug(
"Refusing %s for %s because attr %s (%s) did not match value '%s'"
"For %s on %s attr %s (%s) did not match expected value '%s'"
% (flag, user.username, attr_setting, attribute_value, user_flags_settings.get(is_value_key))
)
new_flag = False
@@ -285,8 +296,16 @@ def _check_flag(user, flag, attributes, user_flags_settings):
logger.debug("Giving %s %s from attribute %s" % (user.username, flag, attr_setting))
new_flag = True
# If the user was flagged and we are going to make them not flagged make sure there is a message
# Get the users old flag
old_value = getattr(user, "is_%s" % (flag))
# If we are not removing the flag and they were a system admin and now we don't want them to be just return
remove_flag = user_flags_settings.get(remove_setting, True)
if not remove_flag and (old_value and not new_flag):
logger.debug("Remove flag %s preventing removal of %s for %s" % (remove_flag, flag, user.username))
return old_value, False
# If the user was flagged and we are going to make them not flagged make sure there is a message
if old_value and not new_flag:
logger.debug("Revoking %s from %s" % (flag, user.username))

View File

@@ -4,8 +4,8 @@ from unittest import mock
from django.utils.timezone import now
from awx.conf.registry import settings_registry
from awx.sso.pipeline import update_user_orgs, update_user_teams, update_user_orgs_by_saml_attr, update_user_teams_by_saml_attr, _check_flag
from awx.main.models import User, Team, Organization, Credential, CredentialType
@@ -92,8 +92,13 @@ class TestSAMLMap:
assert Organization.objects.get(name="Default_Alias") is not None
for o in Organization.objects.all():
assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
if o.name == 'Default':
# The default org was already created and should not have a galaxy credential
assert o.galaxy_credentials.count() == 0
else:
# The Default_Alias was created by SAML and should get the galaxy credential
assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
def test_update_user_teams(self, backend, users, galaxy_credential):
u1, u2, u3 = users
@@ -203,7 +208,13 @@ class TestSAMLAttr:
],
}
return MockSettings()
mock_settings_obj = MockSettings()
for key in settings_registry.get_registered_settings(category_slug='logging'):
value = settings_registry.get_setting_field(key).get_default()
setattr(mock_settings_obj, key, value)
setattr(mock_settings_obj, 'DEBUG', True)
return mock_settings_obj
@pytest.fixture
def backend(self):
@@ -263,8 +274,13 @@ class TestSAMLAttr:
assert Organization.objects.get(name="o1_alias").member_role.members.count() == 1
for o in Organization.objects.all():
assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
if o.id in [o1.id, o2.id, o3.id]:
# o[123] were created without a default galaxy cred
assert o.galaxy_credentials.count() == 0
else:
# anything else created should have a default galaxy cred
assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
def test_update_user_teams_by_saml_attr(self, orgs, users, galaxy_credential, kwargs, mock_settings):
with mock.patch('django.conf.settings', mock_settings):
@@ -322,8 +338,13 @@ class TestSAMLAttr:
assert Team.objects.get(name='Green', organization__name='Default3').member_role.members.count() == 3
for o in Organization.objects.all():
assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
if o.id in [o1.id, o2.id, o3.id]:
# o[123] were created without a default galaxy cred
assert o.galaxy_credentials.count() == 0
else:
# anything else created should have a default galaxy cred
assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
def test_update_user_teams_alias_by_saml_attr(self, orgs, users, galaxy_credential, kwargs, mock_settings):
with mock.patch('django.conf.settings', mock_settings):
@@ -396,73 +417,113 @@ class TestSAMLAttr:
assert o.galaxy_credentials.count() == 1
assert o.galaxy_credentials.first().name == 'Ansible Galaxy'
def test_galaxy_credential_no_auto_assign(self, users, kwargs, galaxy_credential, mock_settings):
# A Galaxy credential should not be added to an existing org
o = Organization.objects.create(name='Default1')
o = Organization.objects.create(name='Default2')
o = Organization.objects.create(name='Default3')
o = Organization.objects.create(name='Default4')
kwargs['response']['attributes']['memberOf'] = ['Default1']
kwargs['response']['attributes']['groups'] = ['Blue']
with mock.patch('django.conf.settings', mock_settings):
for u in users:
update_user_orgs_by_saml_attr(None, None, u, **kwargs)
update_user_teams_by_saml_attr(None, None, u, **kwargs)
assert Organization.objects.count() == 4
for o in Organization.objects.all():
assert o.galaxy_credentials.count() == 0
@pytest.mark.django_db
class TestSAMLUserFlags:
@pytest.mark.parametrize(
"user_flags_settings, expected",
"user_flags_settings, expected, is_superuser",
[
# In this case we will pass no user flags so new_flag should be false and changed will def be false
(
{},
(False, False),
False,
),
# In this case we will give the user a group to make them an admin
(
{'is_superuser_role': 'test-role-1'},
(True, True),
False,
),
# In this case we will give the user a flag that will make then an admin
(
{'is_superuser_attr': 'is_superuser'},
(True, True),
False,
),
# In this case we will give the user a flag but the wrong value
(
{'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'junk'},
(False, False),
False,
),
# In this case we will give the user a flag and the right value
(
{'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'true'},
(True, True),
False,
),
# In this case we will give the user a proper role and an is_superuser_attr role that they dont have, this should make them an admin
(
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'gibberish', 'is_superuser_value': 'true'},
(True, True),
False,
),
# In this case we will give the user a proper role and an is_superuser_attr role that they have, this should make them an admin
(
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'test-role-1'},
(True, True),
False,
),
# In this case we will give the user a proper role and an is_superuser_attr role that they have but a bad value, this should make them an admin
(
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'junk'},
(False, False),
False,
),
# In this case we will give the user everything
(
{'is_superuser_role': 'test-role-1', 'is_superuser_attr': 'is_superuser', 'is_superuser_value': 'true'},
(True, True),
False,
),
# In this test case we will validate that a single attribute (instead of a list) still works
(
{'is_superuser_attr': 'name_id', 'is_superuser_value': 'test_id'},
(True, True),
False,
),
# This will be a negative test for a single atrribute
(
{'is_superuser_attr': 'name_id', 'is_superuser_value': 'junk'},
(False, False),
False,
),
# The user is already a superuser so we should remove them
(
{'is_superuser_attr': 'name_id', 'is_superuser_value': 'junk', 'remove_superusers': True},
(False, True),
True,
),
# The user is already a superuser but we don't have a remove field
(
{'is_superuser_attr': 'name_id', 'is_superuser_value': 'junk', 'remove_superusers': False},
(True, False),
True,
),
],
)
def test__check_flag(self, user_flags_settings, expected):
def test__check_flag(self, user_flags_settings, expected, is_superuser):
user = User()
user.username = 'John'
user.is_superuser = False
user.is_superuser = is_superuser
attributes = {
'email': ['noone@nowhere.com'],

View File

@@ -123,9 +123,11 @@ class TestSAMLUserFlagsAttrField:
{'is_superuser_attr': 'something'},
{'is_superuser_value': 'value'},
{'is_superuser_role': 'my_peeps'},
{'remove_superusers': False},
{'is_system_auditor_attr': 'something_else'},
{'is_system_auditor_value': 'value2'},
{'is_system_auditor_role': 'other_peeps'},
{'remove_system_auditors': False},
],
)
def test_internal_value_valid(self, data):
@@ -165,6 +167,17 @@ class TestSAMLUserFlagsAttrField:
'junk2': ['Invalid field.'],
},
),
# make sure we can't pass a string to the boolean fields
(
{
'remove_superusers': 'test',
'remove_system_auditors': 'test',
},
{
"remove_superusers": ["Must be a valid boolean."],
"remove_system_auditors": ["Must be a valid boolean."],
},
),
],
)
def test_internal_value_invalid(self, data, expected):

View File

@@ -2,15 +2,39 @@ This document is meant to provide some guidance into the functionality of Job Ou
## Overview of the feature/screen. Summary of what it does/is
1. Elapsed time / unfollow button
2. Page up and page down buttons
3. Unique qualities of the different job types.
Joboutput is a feature that allows users to see how their job is doing as it is being run.
This feature displays data sent to the UI via websockets that are connected to several
different endpoints in the API.
- Some dont allow search by event data and thus Event is not an option in the drop down
- Some dont have expand, collapse
The job output has 2 different states that result in different functionality. One state
is when, the job is actively running. There is limited functionality because of how the
job events are processed when they reach the UI. While the job is running, and
output is coming into the UI, the following features turn off:
4. Differences in the output from when a job is running and when a job is complete.
5. Which features are enabled when its running and which arent.
1. [Search](#Search)- The ability to search the output of a job.
2. [Expand/Collapse](#Expand/Collapse)- The ability to expand and collapse job events, tasks, plays, or even the
job itself. The only part of the job ouput that is not collapsable is the playbook summary (only jobs that
are executed from a Job Template have Expand/Collapse functionality).
The following features are enabled:
1. Follow/unfollow - `Follow` indicates you are streaming the output on the screen
as it comes into the UI. If you see some output that you want to examine closer while the job is running
scroll to it, and click `Unfollow`, and the output will stop streaming onto the screen. This feature is only
enabled when the job is running and is not complete. If the user scrolls up in the output the UI will unfollow.
2. Page up and page down buttons- Use these buttons to navigate quickly up and down the output.
![Running job](images/JobOutput-running.png)
After the job is complete, the Follow/Unfollow button disabled, and Expand/Collapse and Search become enabled.
![Finished job](images/JobOutput-complete.png)
Not all job types are created equal. Some jobs have a concept of parent-child events. Job events can be inside a Task,
a Task can be inside a Play, and a Play inside a Playbook. Leveraging this concept to enable Expand/Collapse for these
job types, allows you to collapse and hide the children of a particular line of output. This parent-child event
relationship only exists on jobs executed from a job template. All other types of jobs do not
have this event concept, and therefore, do not have Expand/Collapse functionality. By default all job
events are expanded.
## How output works generally.
@@ -26,11 +50,13 @@ This document is meant to provide some guidance into the functionality of Job Ou
## Non-standard cases
1. When an event comes into the output that has a parent, but the parent hasnt arrived yet.
2. When an event that has children arrives in output, but the children are not present yet
2. When an event with children arrives in output, but the children are not yet present.
## Expand collapse a single event- how it works and how it changes the state object
## Expand/Collapse
## Expand collapse all- how it works and how it changes the state object
### Expand collapse a single event - how it works and how it changes the state object
### Expand collapse all - how it works and how it changes the state object
## Search

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -24,7 +24,7 @@
</script>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-{{ csp_nonce }}' *.pendo.io https://d3js.org; img-src 'self' *.pendo.io data:; worker-src 'self' blob: ;"
content="default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-{{ csp_nonce }}' *.pendo.io; img-src 'self' *.pendo.io data:; worker-src 'self' blob: ;"
/>
<link rel="shortcut icon" href="{% static 'media/favicon.ico' %}" />
<% } else { %>

View File

@@ -0,0 +1,3 @@
/* eslint-disable */
// https://d3js.org/d3-collection/ v1.0.7 Copyright 2018 Mike Bostock
!function(n,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t(n.d3=n.d3||{})}(this,function(n){"use strict";function t(){}function e(n,e){var r=new t;if(n instanceof t)n.each(function(n,t){r.set(t,n)});else if(Array.isArray(n)){var i,u=-1,o=n.length;if(null==e)for(;++u<o;)r.set(u,n[u]);else for(;++u<o;)r.set(e(i=n[u],u,n),i)}else if(n)for(var s in n)r.set(s,n[s]);return r}function r(){return{}}function i(n,t,e){n[t]=e}function u(){return e()}function o(n,t,e){n.set(t,e)}function s(){}t.prototype=e.prototype={constructor:t,has:function(n){return"$"+n in this},get:function(n){return this["$"+n]},set:function(n,t){return this["$"+n]=t,this},remove:function(n){var t="$"+n;return t in this&&delete this[t]},clear:function(){for(var n in this)"$"===n[0]&&delete this[n]},keys:function(){var n=[];for(var t in this)"$"===t[0]&&n.push(t.slice(1));return n},values:function(){var n=[];for(var t in this)"$"===t[0]&&n.push(this[t]);return n},entries:function(){var n=[];for(var t in this)"$"===t[0]&&n.push({key:t.slice(1),value:this[t]});return n},size:function(){var n=0;for(var t in this)"$"===t[0]&&++n;return n},empty:function(){for(var n in this)if("$"===n[0])return!1;return!0},each:function(n){for(var t in this)"$"===t[0]&&n(this[t],t.slice(1),this)}};var f=e.prototype;function c(n,t){var e=new s;if(n instanceof s)n.each(function(n){e.add(n)});else if(n){var r=-1,i=n.length;if(null==t)for(;++r<i;)e.add(n[r]);else for(;++r<i;)e.add(t(n[r],r,n))}return e}s.prototype=c.prototype={constructor:s,has:f.has,add:function(n){return this["$"+(n+="")]=n,this},remove:f.remove,clear:f.clear,values:f.keys,size:f.size,empty:f.empty,each:f.each},n.nest=function(){var n,t,s,f=[],c=[];function a(r,i,u,o){if(i>=f.length)return null!=n&&r.sort(n),null!=t?t(r):r;for(var s,c,h,l=-1,v=r.length,p=f[i++],y=e(),d=u();++l<v;)(h=y.get(s=p(c=r[l])+""))?h.push(c):y.set(s,[c]);return y.each(function(n,t){o(d,t,a(n,i,u,o))}),d}return s={object:function(n){return a(n,0,r,i)},map:function(n){return a(n,0,u,o)},entries:function(n){return function n(e,r){if(++r>f.length)return e;var i,u=c[r-1];return null!=t&&r>=f.length?i=e.entries():(i=[],e.each(function(t,e){i.push({key:e,values:n(t,r)})})),null!=u?i.sort(function(n,t){return u(n.key,t.key)}):i}(a(n,0,u,o),0)},key:function(n){return f.push(n),s},sortKeys:function(n){return c[f.length-1]=n,s},sortValues:function(t){return n=t,s},rollup:function(n){return t=n,s}}},n.set=c,n.map=e,n.keys=function(n){var t=[];for(var e in n)t.push(e);return t},n.values=function(n){var t=[];for(var e in n)t.push(n[e]);return t},n.entries=function(n){var t=[];for(var e in n)t.push({key:e,value:n[e]});return t},Object.defineProperty(n,"__esModule",{value:!0})});

View File

@@ -0,0 +1,3 @@
/* eslint-disable */
// https://d3js.org/d3-dispatch/ v1.0.6 Copyright 2019 Mike Bostock
!function(n,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((n=n||self).d3=n.d3||{})}(this,function(n){"use strict";var e={value:function(){}};function t(){for(var n,e=0,t=arguments.length,o={};e<t;++e){if(!(n=arguments[e]+"")||n in o||/[\s.]/.test(n))throw new Error("illegal type: "+n);o[n]=[]}return new r(o)}function r(n){this._=n}function o(n,e){return n.trim().split(/^|\s+/).map(function(n){var t="",r=n.indexOf(".");if(r>=0&&(t=n.slice(r+1),n=n.slice(0,r)),n&&!e.hasOwnProperty(n))throw new Error("unknown type: "+n);return{type:n,name:t}})}function i(n,e){for(var t,r=0,o=n.length;r<o;++r)if((t=n[r]).name===e)return t.value}function f(n,t,r){for(var o=0,i=n.length;o<i;++o)if(n[o].name===t){n[o]=e,n=n.slice(0,o).concat(n.slice(o+1));break}return null!=r&&n.push({name:t,value:r}),n}r.prototype=t.prototype={constructor:r,on:function(n,e){var t,r=this._,l=o(n+"",r),u=-1,a=l.length;if(!(arguments.length<2)){if(null!=e&&"function"!=typeof e)throw new Error("invalid callback: "+e);for(;++u<a;)if(t=(n=l[u]).type)r[t]=f(r[t],n.name,e);else if(null==e)for(t in r)r[t]=f(r[t],n.name,null);return this}for(;++u<a;)if((t=(n=l[u]).type)&&(t=i(r[t],n.name)))return t},copy:function(){var n={},e=this._;for(var t in e)n[t]=e[t].slice();return new r(n)},call:function(n,e){if((t=arguments.length-2)>0)for(var t,r,o=new Array(t),i=0;i<t;++i)o[i]=arguments[i+2];if(!this._.hasOwnProperty(n))throw new Error("unknown type: "+n);for(i=0,t=(r=this._[n]).length;i<t;++i)r[i].value.apply(e,o)},apply:function(n,e,t){if(!this._.hasOwnProperty(n))throw new Error("unknown type: "+n);for(var r=this._[n],o=0,i=r.length;o<i;++o)r[o].value.apply(e,t)}},n.dispatch=t,Object.defineProperty(n,"__esModule",{value:!0})});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
/* eslint-disable */
// https://d3js.org/d3-timer/ v1.0.10 Copyright 2019 Mike Bostock
!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t=t||self).d3=t.d3||{})}(this,function(t){"use strict";var n,e,o=0,i=0,r=0,u=1e3,l=0,c=0,f=0,a="object"==typeof performance&&performance.now?performance:Date,s="object"==typeof window&&window.requestAnimationFrame?window.requestAnimationFrame.bind(window):function(t){setTimeout(t,17)};function _(){return c||(s(m),c=a.now()+f)}function m(){c=0}function p(){this._call=this._time=this._next=null}function w(t,n,e){var o=new p;return o.restart(t,n,e),o}function d(){_(),++o;for(var t,e=n;e;)(t=c-e._time)>=0&&e._call.call(null,t),e=e._next;--o}function h(){c=(l=a.now())+f,o=i=0;try{d()}finally{o=0,function(){var t,o,i=n,r=1/0;for(;i;)i._call?(r>i._time&&(r=i._time),t=i,i=i._next):(o=i._next,i._next=null,i=t?t._next=o:n=o);e=t,v(r)}(),c=0}}function y(){var t=a.now(),n=t-l;n>u&&(f-=n,l=t)}function v(t){o||(i&&(i=clearTimeout(i)),t-c>24?(t<1/0&&(i=setTimeout(h,t-a.now()-f)),r&&(r=clearInterval(r))):(r||(l=a.now(),r=setInterval(y,u)),o=1,s(h)))}p.prototype=w.prototype={constructor:p,restart:function(t,o,i){if("function"!=typeof t)throw new TypeError("callback is not a function");i=(null==i?_():+i)+(null==o?0:+o),this._next||e===this||(e?e._next=this:n=this,e=this),this._call=t,this._time=i,v()},stop:function(){this._call&&(this._call=null,this._time=1/0,v())}},t.interval=function(t,n,e){var o=new p,i=n;return null==n?(o.restart(t,n,e),o):(n=+n,e=null==e?_():+e,o.restart(function r(u){u+=i,o.restart(r,i+=n,e),t(u)},n,e),o)},t.now=_,t.timeout=function(t,n,e){var o=new p;return n=null==n?0:+n,o.restart(function(e){o.stop(),t(e+n)},n,e),o},t.timer=w,t.timerFlush=d,Object.defineProperty(t,"__esModule",{value:!0})});

View File

@@ -41,6 +41,7 @@ const Detail = ({
className,
dataCy,
alwaysVisible,
isEmpty,
helpText,
isEncrypted,
isNotConfigured,
@@ -49,6 +50,10 @@ const Detail = ({
return null;
}
if (isEmpty && !alwaysVisible) {
return null;
}
const labelCy = dataCy ? `${dataCy}-label` : null;
const valueCy = dataCy ? `${dataCy}-value` : null;

View File

@@ -163,16 +163,16 @@ function JobListItem({
<Td colSpan={showTypeColumn ? 6 : 5}>
<ExpandableRowContent>
<DetailList>
{job.type === 'inventory_update' &&
inventorySourceLabels.length > 0 && (
<Detail
dataCy="job-inventory-source-type"
label={t`Source`}
value={inventorySourceLabels.map(([string, label]) =>
string === job.source ? label : null
)}
/>
)}
{job.type === 'inventory_update' && (
<Detail
dataCy="job-inventory-source-type"
label={t`Source`}
value={inventorySourceLabels?.map(([string, label]) =>
string === job.source ? label : null
)}
isEmpty={inventorySourceLabels?.length === 0}
/>
)}
<LaunchedByDetail job={job} />
{job.launch_type === 'scheduled' &&
(schedule ? (
@@ -254,7 +254,7 @@ function JobListItem({
dataCy={`execution-environment-detail-${job.id}`}
/>
)}
{credentials && credentials.length > 0 && (
{credentials && (
<Detail
fullWidth
label={t`Credentials`}
@@ -275,6 +275,7 @@ function JobListItem({
))}
</ChipGroup>
}
isEmpty={credentials.length === 0}
/>
)}
{labels && labels.count > 0 && (

View File

@@ -203,6 +203,49 @@ describe('<JobListItem />', () => {
wrapper.find('Detail[label="Execution Environment"] dd').text()
).toBe('Missing resource');
});
test('should not load Source', () => {
wrapper = mountWithContexts(
<table>
<tbody>
<JobListItem
inventorySourceLabels={[]}
job={{
...mockJob,
type: 'inventory_update',
summary_fields: {
user_capabilities: {},
},
}}
/>
</tbody>
</table>
);
const source_detail = wrapper.find(`Detail[label="Source"]`).at(0);
expect(source_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Credentials', () => {
wrapper = mountWithContexts(
<table>
<tbody>
<JobListItem
job={{
...mockJob,
type: 'inventory_update',
summary_fields: {
credentials: [],
},
}}
/>
</tbody>
</table>
);
const credentials_detail = wrapper
.find(`Detail[label="Credentials"]`)
.at(0);
expect(credentials_detail.prop('isEmpty')).toEqual(true);
});
});
describe('<JobListItem with failed job />', () => {

View File

@@ -29,17 +29,11 @@ function PromptInventorySourceDetail({ resource }) {
summary_fields,
update_cache_timeout,
update_on_launch,
update_on_project_update,
verbosity,
} = resource;
let optionsList = '';
if (
overwrite ||
overwrite_vars ||
update_on_launch ||
update_on_project_update
) {
if (overwrite || overwrite_vars || update_on_launch) {
optionsList = (
<TextList component={TextListVariants.ul}>
{overwrite && (
@@ -57,11 +51,6 @@ function PromptInventorySourceDetail({ resource }) {
{t`Update on launch`}
</TextListItem>
)}
{update_on_project_update && (
<TextListItem component={TextListItemVariants.li}>
{t`Update on project update`}
</TextListItem>
)}
</TextList>
);
}
@@ -113,15 +102,14 @@ function PromptInventorySourceDetail({ resource }) {
label={t`Cache Timeout`}
value={`${update_cache_timeout} ${t`Seconds`}`}
/>
{summary_fields?.credentials?.length > 0 && (
<Detail
fullWidth
label={t`Credential`}
value={summary_fields.credentials.map((cred) => (
<CredentialChip key={cred?.id} credential={cred} isReadOnly />
))}
/>
)}
<Detail
fullWidth
label={t`Credential`}
value={summary_fields?.credentials?.map((cred) => (
<CredentialChip key={cred?.id} credential={cred} isReadOnly />
))}
isEmpty={summary_fields?.credentials?.length === 0}
/>
{source_regions && (
<Detail
fullWidth

View File

@@ -67,7 +67,6 @@ describe('PromptInventorySourceDetail', () => {
</li>,
<li>Overwrite local variables from remote inventory source</li>,
<li>Update on launch</li>,
<li>Update on project update</li>,
])
).toEqual(true);
});
@@ -79,4 +78,19 @@ describe('PromptInventorySourceDetail', () => {
);
assertDetail(wrapper, 'Organization', 'Deleted');
});
test('should not load Credentials', () => {
wrapper = mountWithContexts(
<PromptInventorySourceDetail
resource={{
...mockInvSource,
summary_fields: {
credentials: [],
},
}}
/>
);
const credentials_detail = wrapper.find(`Detail[label="Credential"]`).at(0);
expect(credentials_detail.prop('isEmpty')).toEqual(true);
});
});

View File

@@ -26,7 +26,7 @@ function PromptJobTemplateDetail({ resource }) {
extra_vars,
forks,
host_config_key,
instance_groups,
instance_groups = [],
job_slice_count,
job_tags,
job_type,
@@ -94,9 +94,11 @@ function PromptJobTemplateDetail({ resource }) {
return (
<>
{summary_fields.recent_jobs?.length > 0 && (
<Detail value={<Sparkline jobs={recentJobs} />} label={t`Activity`} />
)}
<Detail
label={t`Activity`}
value={<Sparkline jobs={recentJobs} />}
isEmpty={summary_fields.recent_jobs?.length === 0}
/>
<Detail label={t`Job Type`} value={toTitleCase(job_type)} />
{summary_fields?.organization ? (
<Detail
@@ -180,7 +182,7 @@ function PromptJobTemplateDetail({ resource }) {
/>
)}
{optionsList && <Detail label={t`Enabled Options`} value={optionsList} />}
{summary_fields?.credentials?.length > 0 && (
{summary_fields?.credentials && (
<Detail
fullWidth
label={t`Credentials`}
@@ -195,9 +197,10 @@ function PromptJobTemplateDetail({ resource }) {
))}
</ChipGroup>
}
isEmpty={summary_fields?.credentials?.length === 0}
/>
)}
{summary_fields?.labels?.results?.length > 0 && (
{summary_fields?.labels?.results && (
<Detail
fullWidth
label={t`Labels`}
@@ -214,28 +217,28 @@ function PromptJobTemplateDetail({ resource }) {
))}
</ChipGroup>
}
isEmpty={summary_fields?.labels?.results?.length === 0}
/>
)}
{instance_groups?.length > 0 && (
<Detail
fullWidth
label={t`Instance Groups`}
value={
<ChipGroup
numChips={5}
totalChips={instance_groups.length}
ouiaId="prompt-jt-instance-group-chips"
>
{instance_groups.map((ig) => (
<Chip key={ig.id} isReadOnly>
{ig.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
{job_tags?.length > 0 && (
<Detail
fullWidth
label={t`Instance Groups`}
value={
<ChipGroup
numChips={5}
totalChips={instance_groups?.length}
ouiaId="prompt-jt-instance-group-chips"
>
{instance_groups?.map((ig) => (
<Chip key={ig.id} isReadOnly>
{ig.name}
</Chip>
))}
</ChipGroup>
}
isEmpty={instance_groups?.length === 0}
/>
{job_tags && (
<Detail
fullWidth
label={t`Job Tags`}
@@ -252,9 +255,10 @@ function PromptJobTemplateDetail({ resource }) {
))}
</ChipGroup>
}
isEmpty={job_tags?.length === 0}
/>
)}
{skip_tags?.length > 0 && (
{skip_tags && (
<Detail
fullWidth
label={t`Skip Tags`}
@@ -271,6 +275,7 @@ function PromptJobTemplateDetail({ resource }) {
))}
</ChipGroup>
}
isEmpty={skip_tags?.length === 0}
/>
)}
{extra_vars && (

View File

@@ -125,4 +125,92 @@ describe('PromptJobTemplateDetail', () => {
assertDetail(wrapper, 'Organization', 'Deleted');
assertDetail(wrapper, 'Project', 'Deleted');
});
test('should not load Activity', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
summary_fields: {
recent_jobs: [],
},
}}
/>
);
const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0);
expect(activity_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Credentials', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
summary_fields: {
credentials: [],
},
}}
/>
);
const credentials_detail = wrapper
.find(`Detail[label="Credentials"]`)
.at(0);
expect(credentials_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Labels', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
summary_fields: {
labels: {
results: [],
},
},
}}
/>
);
const labels_detail = wrapper.find(`Detail[label="Labels"]`).at(0);
expect(labels_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Instance Groups', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
instance_groups: [],
}}
/>
);
const instance_groups_detail = wrapper
.find(`Detail[label="Instance Groups"]`)
.at(0);
expect(instance_groups_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Job Tags', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
job_tags: '',
}}
/>
);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
});
test('should not load Skip Tags', () => {
wrapper = mountWithContexts(
<PromptJobTemplateDetail
resource={{
...mockJT,
skip_tags: '',
}}
/>
);
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
});
});

View File

@@ -57,9 +57,11 @@ function PromptWFJobTemplateDetail({ resource }) {
return (
<>
{summary_fields?.recent_jobs?.length > 0 && (
<Detail value={<Sparkline jobs={recentJobs} />} label={t`Activity`} />
)}
<Detail
label={t`Activity`}
value={<Sparkline jobs={recentJobs} />}
isEmpty={summary_fields?.recent_jobs?.length === 0}
/>
{summary_fields?.organization && (
<Detail
label={t`Organization`}
@@ -108,7 +110,7 @@ function PromptWFJobTemplateDetail({ resource }) {
}
/>
)}
{summary_fields?.labels?.results?.length > 0 && (
{summary_fields?.labels?.results && (
<Detail
fullWidth
label={t`Labels`}
@@ -125,6 +127,7 @@ function PromptWFJobTemplateDetail({ resource }) {
))}
</ChipGroup>
}
isEmpty={summary_fields?.labels?.results?.length === 0}
/>
)}
{extra_vars && (

View File

@@ -62,4 +62,36 @@ describe('PromptWFJobTemplateDetail', () => {
'---\nmock: data'
);
});
test('should not load Activity', () => {
wrapper = mountWithContexts(
<PromptWFJobTemplateDetail
resource={{
...mockWF,
summary_fields: {
recent_jobs: [],
},
}}
/>
);
const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0);
expect(activity_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Labels', () => {
wrapper = mountWithContexts(
<PromptWFJobTemplateDetail
resource={{
...mockWF,
summary_fields: {
labels: {
results: [],
},
},
}}
/>
);
const labels_detail = wrapper.find(`Detail[label="Labels"]`).at(0);
expect(labels_detail.prop('isEmpty')).toEqual(true);
});
});

View File

@@ -112,7 +112,6 @@
"update_on_launch":true,
"update_cache_timeout":2,
"source_project":8,
"update_on_project_update":true,
"last_update_failed": true,
"last_updated":null
}

View File

@@ -68,34 +68,32 @@ function ResourceAccessListItem({ accessRecord, onRoleDelete }) {
<Td dataLabel={t`Last name`}>{accessRecord.last_name}</Td>
<Td dataLabel={t`Roles`}>
<DetailList stacked>
{userRoles.length > 0 && (
<Detail
label={t`User Roles`}
value={
<ChipGroup
numChips={5}
totalChips={userRoles.length}
ouiaId="user-role-chips"
>
{userRoles.map(renderChip)}
</ChipGroup>
}
/>
)}
{teamRoles.length > 0 && (
<Detail
label={t`Team Roles`}
value={
<ChipGroup
numChips={5}
totalChips={teamRoles.length}
ouiaId="team-role-chips"
>
{teamRoles.map(renderChip)}
</ChipGroup>
}
/>
)}
<Detail
label={t`User Roles`}
value={
<ChipGroup
numChips={5}
totalChips={userRoles.length}
ouiaId="user-role-chips"
>
{userRoles.map(renderChip)}
</ChipGroup>
}
isEmpty={userRoles.length === 0}
/>
<Detail
label={t`Team Roles`}
value={
<ChipGroup
numChips={5}
totalChips={teamRoles.length}
ouiaId="team-role-chips"
>
{teamRoles.map(renderChip)}
</ChipGroup>
}
isEmpty={teamRoles.length === 0}
/>
</DetailList>
</Td>
</Tr>

View File

@@ -53,5 +53,41 @@ describe('<ResourceAccessListItem />', () => {
expect(wrapper.find('Td[dataLabel="First name"]').text()).toBe('jane');
expect(wrapper.find('Td[dataLabel="Last name"]').text()).toBe('brown');
const user_roles_detail = wrapper.find(`Detail[label="User Roles"]`).at(0);
expect(user_roles_detail.prop('isEmpty')).toEqual(true);
});
test('should not load team roles', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<table>
<tbody>
<ResourceAccessListItem
accessRecord={{
...accessRecord,
summary_fields: {
direct_access: [
{
role: {
id: 3,
name: 'Member',
user_capabilities: { unattach: true },
},
},
],
indirect_access: [],
},
}}
onRoleDelete={() => {}}
/>
</tbody>
</table>
);
});
const team_roles_detail = wrapper.find(`Detail[label="Team Roles"]`).at(0);
expect(team_roles_detail.prop('isEmpty')).toEqual(true);
});
});

View File

@@ -272,11 +272,12 @@ function TemplateListItem({
value={template.description}
dataCy={`template-${template.id}-description`}
/>
{summaryFields.recent_jobs && summaryFields.recent_jobs.length ? (
{summaryFields.recent_jobs ? (
<Detail
label={t`Activity`}
value={<Sparkline jobs={summaryFields.recent_jobs} />}
dataCy={`template-${template.id}-activity`}
isEmpty={summaryFields.recent_jobs.length === 0}
/>
) : null}
{summaryFields.inventory ? (
@@ -316,7 +317,7 @@ function TemplateListItem({
value={formatDateString(template.modified)}
dataCy={`template-${template.id}-last-modified`}
/>
{summaryFields.credentials && summaryFields.credentials.length ? (
{summaryFields.credentials ? (
<Detail
fullWidth
label={t`Credentials`}
@@ -337,9 +338,10 @@ function TemplateListItem({
</ChipGroup>
}
dataCy={`template-${template.id}-credentials`}
isEmpty={summaryFields.credentials.length === 0}
/>
) : null}
{summaryFields.labels && summaryFields.labels.results.length > 0 && (
{summaryFields.labels && (
<Detail
fullWidth
label={t`Labels`}
@@ -361,6 +363,7 @@ function TemplateListItem({
</ChipGroup>
}
dataCy={`template-${template.id}-labels`}
isEmpty={summaryFields.labels.results.length === 0}
/>
)}
</DetailList>

View File

@@ -465,4 +465,68 @@ describe('<TemplateListItem />', () => {
).toEqual(true);
expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength(1);
});
test('should not load Activity', async () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
template={{
...mockJobTemplateData,
summary_fields: {
user_capabilities: {},
recent_jobs: [],
},
}}
/>
</tbody>
</table>
);
const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0);
expect(activity_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Credentials', async () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
template={{
...mockJobTemplateData,
summary_fields: {
user_capabilities: {},
credentials: [],
},
}}
/>
</tbody>
</table>
);
const credentials_detail = wrapper
.find(`Detail[label="Credentials"]`)
.at(0);
expect(credentials_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Labels', async () => {
const wrapper = mountWithContexts(
<table>
<tbody>
<TemplateListItem
template={{
...mockJobTemplateData,
summary_fields: {
user_capabilities: {},
labels: {
results: [],
},
},
}}
/>
</tbody>
</table>
);
const labels_detail = wrapper.find(`Detail[label="Labels"]`).at(0);
expect(labels_detail.prop('isEmpty')).toEqual(true);
});
});

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

@@ -264,20 +264,19 @@ function CredentialDetail({ credential }) {
date={modified}
user={modified_by}
/>
{enabledBooleanFields.length > 0 && (
<Detail
label={t`Enabled Options`}
value={
<TextList component={TextListVariants.ul}>
{enabledBooleanFields.map(({ id, label }) => (
<TextListItem key={id} component={TextListItemVariants.li}>
{label}
</TextListItem>
))}
</TextList>
}
/>
)}
<Detail
label={t`Enabled Options`}
value={
<TextList component={TextListVariants.ul}>
{enabledBooleanFields.map(({ id, label }) => (
<TextListItem key={id} component={TextListItemVariants.li}>
{label}
</TextListItem>
))}
</TextList>
}
isEmpty={enabledBooleanFields.length === 0}
/>
</DetailList>
{Object.keys(inputSources).length > 0 && (
<PluginFieldText>

View File

@@ -149,4 +149,23 @@ describe('<CredentialDetail />', () => {
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
});
});
test('should not load enabled options', async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialDetail
credential={{
...mockCredential,
results: {
inputs: null,
},
}}
/>
);
});
const enabled_options_detail = wrapper
.find(`Detail[label="Enabled Options"]`)
.at(0);
expect(enabled_options_detail.prop('isEmpty')).toEqual(true);
});
});

View File

@@ -67,9 +67,11 @@ function HostDetail({ host }) {
<HostToggle host={host} css="padding-bottom: 40px" />
<DetailList gutter="sm">
<Detail label={t`Name`} value={name} dataCy="host-name" />
{recentJobs?.length > 0 && (
<Detail label={t`Activity`} value={<Sparkline jobs={recentJobs} />} />
)}
<Detail
label={t`Activity`}
value={<Sparkline jobs={recentJobs} />}
isEmpty={recentJobs?.length === 0}
/>
<Detail label={t`Description`} value={description} />
<Detail
label={t`Inventory`}

View File

@@ -81,6 +81,8 @@ describe('<HostDetail />', () => {
expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength(
0
);
const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0);
expect(activity_detail.prop('isEmpty')).toEqual(true);
});
test('should hide edit button for users without edit permission', async () => {

View File

@@ -79,17 +79,17 @@ function InventoryDetail({ inventory }) {
}
/>
<Detail label={t`Total hosts`} value={inventory.total_hosts} />
{instanceGroups && instanceGroups.length > 0 && (
{instanceGroups && (
<Detail
fullWidth
label={t`Instance Groups`}
value={
<ChipGroup
numChips={5}
totalChips={instanceGroups.length}
totalChips={instanceGroups?.length}
ouiaId="instance-group-chips"
>
{instanceGroups.map((ig) => (
{instanceGroups?.map((ig) => (
<Chip
key={ig.id}
isReadOnly
@@ -100,28 +100,29 @@ function InventoryDetail({ inventory }) {
))}
</ChipGroup>
}
isEmpty={instanceGroups.length === 0}
/>
)}
{inventory.summary_fields.labels && (
<Detail
fullWidth
helpText={helpText.labels}
label={t`Labels`}
value={
<ChipGroup
numChips={5}
totalChips={inventory.summary_fields.labels?.results?.length}
>
{inventory.summary_fields.labels?.results?.map((l) => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
isEmpty={inventory.summary_fields.labels?.results?.length === 0}
/>
)}
{inventory.summary_fields.labels &&
inventory.summary_fields.labels?.results?.length > 0 && (
<Detail
fullWidth
helpText={helpText.labels}
label={t`Labels`}
value={
<ChipGroup
numChips={5}
totalChips={inventory.summary_fields.labels.results.length}
>
{inventory.summary_fields.labels.results.map((l) => (
<Chip key={l.id} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
<VariablesDetail
label={t`Variables`}
helpText={helpText.variables()}

View File

@@ -153,6 +153,9 @@ describe('<InventoryDetail />', () => {
expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledWith(
mockInventory.id
);
expect(wrapper.find(`Detail[label="Instance Groups"]`)).toHaveLength(0);
const instance_groups_detail = wrapper
.find(`Detail[label="Instance Groups"]`)
.at(0);
expect(instance_groups_detail.prop('isEmpty')).toEqual(true);
});
});

View File

@@ -72,12 +72,11 @@ function InventoryHostDetail({ host }) {
<HostToggle host={host} css="padding-bottom: 40px" />
<DetailList gutter="sm">
<Detail label={t`Name`} value={name} />
{recentPlaybookJobs?.length > 0 && (
<Detail
label={t`Activity`}
value={<Sparkline jobs={recentPlaybookJobs} />}
/>
)}
<Detail
label={t`Activity`}
value={<Sparkline jobs={recentPlaybookJobs} />}
isEmpty={recentPlaybookJobs?.length === 0}
/>
<Detail label={t`Description`} value={description} />
<UserDateDetail date={created} label={t`Created`} user={created_by} />
<UserDateDetail

View File

@@ -91,6 +91,8 @@ describe('<InventoryHostDetail />', () => {
expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength(
0
);
const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0);
expect(activity_detail.prop('isEmpty')).toEqual(true);
});
test('should hide edit button for users without edit permission', async () => {

View File

@@ -216,7 +216,7 @@ function InventoryList() {
headerRow={
<HeaderRow qsConfig={QS_CONFIG}>
<HeaderCell sortKey="name">{t`Name`}</HeaderCell>
<HeaderCell>{t`Status`}</HeaderCell>
<HeaderCell>{t`Sync Status`}</HeaderCell>
<HeaderCell>{t`Type`}</HeaderCell>
<HeaderCell>{t`Organization`}</HeaderCell>
<HeaderCell>{t`Actions`}</HeaderCell>

View File

@@ -31,7 +31,6 @@ describe('<InventorySourceAdd />', () => {
source_vars: '---↵',
update_cache_timeout: 0,
update_on_launch: false,
update_on_project_update: false,
verbosity: 1,
};

View File

@@ -49,7 +49,6 @@ function InventorySourceDetail({ inventorySource }) {
source_vars,
update_cache_timeout,
update_on_launch,
update_on_project_update,
verbosity,
enabled_var,
enabled_value,
@@ -113,12 +112,7 @@ function InventorySourceDetail({ inventorySource }) {
);
let optionsList = '';
if (
overwrite ||
overwrite_vars ||
update_on_launch ||
update_on_project_update
) {
if (overwrite || overwrite_vars || update_on_launch) {
optionsList = (
<TextList component={TextListVariants.ul}>
{overwrite && (
@@ -143,16 +137,6 @@ function InventorySourceDetail({ inventorySource }) {
/>
</TextListItem>
)}
{update_on_project_update && (
<TextListItem component={TextListItemVariants.li}>
{t`Update on project update`}
<Popover
content={helpText.subFormOptions.updateOnProjectUpdate({
value: source_project,
})}
/>
</TextListItem>
)}
</TextList>
);
}
@@ -268,15 +252,14 @@ function InventorySourceDetail({ inventorySource }) {
helpText={helpText.enabledValue}
value={enabled_value}
/>
{credentials?.length > 0 && (
<Detail
fullWidth
label={t`Credential`}
value={credentials.map((cred) => (
<CredentialChip key={cred?.id} credential={cred} isReadOnly />
))}
/>
)}
<Detail
fullWidth
label={t`Credential`}
value={credentials?.map((cred) => (
<CredentialChip key={cred?.id} credential={cred} isReadOnly />
))}
isEmpty={credentials?.length === 0}
/>
{optionsList && (
<Detail fullWidth label={t`Enabled Options`} value={optionsList} />
)}

View File

@@ -113,7 +113,6 @@ describe('InventorySourceDetail', () => {
'Overwrite local groups and hosts from remote inventory source',
'Overwrite local variables from remote inventory source',
'Update on launch',
'Update on project update',
]).toContain(option.text());
});
});
@@ -237,4 +236,21 @@ describe('InventorySourceDetail', () => {
(el) => el.length === 0
);
});
test('should not load Credentials', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InventorySourceDetail
inventorySource={{
...mockInvSource,
summary_fields: {
credentials: [],
},
}}
/>
);
});
const credentials_detail = wrapper.find(`Detail[label="Credential"]`).at(0);
expect(credentials_detail.prop('isEmpty')).toEqual(true);
});
});

View File

@@ -31,7 +31,6 @@ describe('<InventorySourceEdit />', () => {
source_vars: '---↵',
update_cache_timeout: 0,
update_on_launch: false,
update_on_project_update: false,
verbosity: 1,
};
const mockInventory = {

View File

@@ -96,12 +96,11 @@ function SmartInventoryDetail({ inventory }) {
<CardBody>
<DetailList>
<Detail label={t`Name`} value={name} />
{recentJobs.length > 0 && (
<Detail
label={t`Activity`}
value={<Sparkline jobs={recentJobs} />}
/>
)}
<Detail
label={t`Activity`}
value={<Sparkline jobs={recentJobs} />}
isEmpty={recentJobs.length === 0}
/>
<Detail label={t`Description`} value={description} />
<Detail label={t`Type`} value={t`Smart inventory`} />
<Detail
@@ -118,29 +117,28 @@ function SmartInventoryDetail({ inventory }) {
value={<Label variant="outline">{host_filter}</Label>}
/>
<Detail label={t`Total hosts`} value={total_hosts} />
{instanceGroups.length > 0 && (
<Detail
fullWidth
label={t`Instance groups`}
value={
<ChipGroup
numChips={5}
totalChips={instanceGroups.length}
ouiaId="instance-group-chips"
>
{instanceGroups.map((ig) => (
<Chip
key={ig.id}
isReadOnly
ouiaId={`instance-group-${ig.id}-chip`}
>
{ig.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
<Detail
fullWidth
label={t`Instance groups`}
value={
<ChipGroup
numChips={5}
totalChips={instanceGroups.length}
ouiaId="instance-group-chips"
>
{instanceGroups.map((ig) => (
<Chip
key={ig.id}
isReadOnly
ouiaId={`instance-group-${ig.id}-chip`}
>
{ig.name}
</Chip>
))}
</ChipGroup>
}
isEmpty={instanceGroups.length === 0}
/>
<VariablesDetail
label={t`Variables`}
value={variables}

View File

@@ -112,6 +112,41 @@ describe('<SmartInventoryDetail />', () => {
(el) => el.length === 0
);
});
test('should not load Activity', async () => {
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryDetail
inventory={{
...mockSmartInventory,
recent_jobs: [],
}}
/>
);
});
const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0);
expect(activity_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Instance Groups', async () => {
InventoriesAPI.readInstanceGroups.mockResolvedValue({
data: {
results: [],
},
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<SmartInventoryDetail inventory={mockSmartInventory} />
);
});
wrapper.update();
const instance_groups_detail = wrapper
.find(`Detail[label="Instance groups"]`)
.at(0);
expect(instance_groups_detail.prop('isEmpty')).toEqual(true);
});
});
describe('User has read-only permissions', () => {

View File

@@ -28,12 +28,11 @@ function SmartInventoryHostDetail({ host }) {
<CardBody>
<DetailList gutter="sm">
<Detail label={t`Name`} value={name} />
{recentPlaybookJobs?.length > 0 && (
<Detail
label={t`Activity`}
value={<Sparkline jobs={recentPlaybookJobs} />}
/>
)}
<Detail
label={t`Activity`}
value={<Sparkline jobs={recentPlaybookJobs} />}
isEmpty={recentPlaybookJobs?.length === 0}
/>
<Detail label={t`Description`} value={description} />
<Detail
label={t`Inventory`}

View File

@@ -27,4 +27,19 @@ describe('<SmartInventoryHostDetail />', () => {
expect(wrapper.find('Detail[label="Activity"] Sparkline')).toHaveLength(1);
expect(wrapper.find('VariablesDetail')).toHaveLength(1);
});
test('should not load Activity', () => {
wrapper = mountWithContexts(
<SmartInventoryHostDetail
host={{
...mockHost,
summary_fields: {
recent_jobs: [],
},
}}
/>
);
const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0);
expect(activity_detail.prop('isEmpty')).toEqual(true);
});
});

View File

@@ -73,7 +73,6 @@ const InventorySourceFormFields = ({
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: false,
update_on_project_update: false,
verbosity: 1,
enabled_var: '',
enabled_value: '',
@@ -251,7 +250,6 @@ const InventorySourceForm = ({
source_vars: source?.source_vars || '---\n',
update_cache_timeout: source?.update_cache_timeout || 0,
update_on_launch: source?.update_on_launch || false,
update_on_project_update: source?.update_on_project_update || false,
verbosity: source?.verbosity || 1,
enabled_var: source?.enabled_var || '',
enabled_value: source?.enabled_value || '',

View File

@@ -17,7 +17,6 @@ const initialValues = {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};

View File

@@ -17,7 +17,6 @@ const initialValues = {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};

View File

@@ -17,7 +17,6 @@ const initialValues = {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};

View File

@@ -17,7 +17,6 @@ const initialValues = {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};

View File

@@ -17,7 +17,6 @@ const initialValues = {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};

View File

@@ -17,7 +17,6 @@ const initialValues = {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};

View File

@@ -17,7 +17,6 @@ const initialValues = {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
@@ -121,7 +120,6 @@ describe('<SCMSubForm />', () => {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
let customWrapper;
@@ -139,63 +137,4 @@ describe('<SCMSubForm />', () => {
customWrapper.update();
expect(customWrapper.find('Select').prop('selections')).toBe('newPath');
});
test('Update on project update should be disabled', async () => {
const customInitialValues = {
credential: { id: 1, name: 'Credential' },
custom_virtualenv: '',
overwrite: false,
overwrite_vars: false,
source_path: '/path',
source_project: { id: 1, name: 'Source project' },
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};
let customWrapper;
await act(async () => {
customWrapper = mountWithContexts(
<Formik initialValues={customInitialValues}>
<SCMSubForm />
</Formik>
);
});
expect(
customWrapper
.find('Checkbox[aria-label="Update on project update"]')
.prop('isDisabled')
).toBe(true);
});
test('Update on launch should be disabled', async () => {
const customInitialValues = {
credential: { id: 1, name: 'Credential' },
custom_virtualenv: '',
overwrite: false,
overwrite_vars: false,
source_path: '/path',
source_project: { id: 1, name: 'Source project' },
source_script: null,
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: false,
update_on_project_update: true,
verbosity: 1,
};
let customWrapper;
await act(async () => {
customWrapper = mountWithContexts(
<Formik initialValues={customInitialValues}>
<SCMSubForm />
</Formik>
);
});
expect(
customWrapper
.find('Checkbox[aria-label="Update on launch"]')
.prop('isDisabled')
).toBe(true);
});
});

View File

@@ -17,7 +17,6 @@ const initialValues = {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};

View File

@@ -53,11 +53,10 @@ export const VerbosityField = () => {
);
};
export const OptionsField = ({ showProjectUpdate = false }) => {
export const OptionsField = () => {
const [updateOnLaunchField] = useField('update_on_launch');
const [, , updateCacheTimeoutHelper] = useField('update_cache_timeout');
const [projectField] = useField('source_project');
const [updatedOnProjectUpdateField] = useField('update_on_project_update');
useEffect(() => {
if (!updateOnLaunchField.value) {
@@ -83,23 +82,11 @@ export const OptionsField = ({ showProjectUpdate = false }) => {
tooltip={helpText.subFormOptions.overwriteVariables}
/>
<CheckboxField
isDisabled={updatedOnProjectUpdateField.value}
id="update_on_launch"
name="update_on_launch"
label={t`Update on launch`}
tooltip={helpText.subFormOptions.updateOnLaunch(projectField)}
/>
{showProjectUpdate && (
<CheckboxField
isDisabled={updateOnLaunchField.value}
id="update_on_project_update"
name="update_on_project_update"
label={t`Update on project update`}
tooltip={helpText.subFormOptions.updateOnProjectUpdate(
projectField
)}
/>
)}
</FormCheckboxLayout>
</FormGroup>
</FormFullWidthLayout>

View File

@@ -17,7 +17,6 @@ const initialValues = {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};

View File

@@ -17,7 +17,6 @@ const initialValues = {
source_vars: '---\n',
update_cache_timeout: 0,
update_on_launch: true,
update_on_project_update: false,
verbosity: 1,
};

View File

@@ -115,7 +115,6 @@
"update_on_launch":true,
"update_cache_timeout":2,
"source_project":8,
"update_on_project_update":true,
"last_update_failed": true,
"last_updated":null,
"execution_environment": 1

View File

@@ -268,15 +268,14 @@ function JobDetail({ job, inventorySourceLabels }) {
</Link>
}
/>
{inventorySourceLabels.length > 0 && (
<Detail
dataCy="job-inventory-source-type"
label={t`Source`}
value={inventorySourceLabels.map(([string, label]) =>
string === job.source ? label : null
)}
/>
)}
<Detail
dataCy="job-inventory-source-type"
label={t`Source`}
value={inventorySourceLabels.map(([string, label]) =>
string === job.source ? label : null
)}
isEmpty={inventorySourceLabels.length === 0}
/>
</>
)}
{inventory_source && inventory_source.source === 'scm' && (
@@ -406,7 +405,7 @@ function JobDetail({ job, inventorySourceLabels }) {
}
/>
)}
{credentials && credentials.length > 0 && (
{credentials && (
<Detail
dataCy="job-credentials"
fullWidth
@@ -428,6 +427,7 @@ function JobDetail({ job, inventorySourceLabels }) {
))}
</ChipGroup>
}
isEmpty={credentials.length === 0}
/>
)}
{labels && labels.count > 0 && (
@@ -451,7 +451,7 @@ function JobDetail({ job, inventorySourceLabels }) {
}
/>
)}
{job.job_tags && job.job_tags.length > 0 && (
{job.job_tags && (
<Detail
dataCy="job-tags"
fullWidth
@@ -474,9 +474,10 @@ function JobDetail({ job, inventorySourceLabels }) {
))}
</ChipGroup>
}
isEmpty={job.job_tags.length === 0}
/>
)}
{job.skip_tags && job.skip_tags.length > 0 && (
{job.skip_tags && (
<Detail
dataCy="job-skip-tags"
fullWidth
@@ -499,6 +500,7 @@ function JobDetail({ job, inventorySourceLabels }) {
))}
</ChipGroup>
}
isEmpty={job.skip_tags.length === 0}
/>
)}
<Detail

View File

@@ -548,4 +548,64 @@ describe('<JobDetail />', () => {
assertDetail('Inventory', 'Demo Inventory');
assertDetail('Job Slice Parent', 'True');
});
test('should not load Source', () => {
wrapper = mountWithContexts(
<JobDetail
job={{
...mockJobData,
summary_fields: {
inventory_source: {},
user_capabilities: {},
inventory: { id: 1 },
},
}}
inventorySourceLabels={[]}
/>
);
const source_detail = wrapper.find(`Detail[label="Source"]`).at(0);
expect(source_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Credentials', () => {
wrapper = mountWithContexts(
<JobDetail
job={{
...mockJobData,
summary_fields: {
user_capabilities: {},
credentials: [],
},
}}
/>
);
const credentials_detail = wrapper
.find(`Detail[label="Credentials"]`)
.at(0);
expect(credentials_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Job Tags', () => {
wrapper = mountWithContexts(
<JobDetail
job={{
...mockJobData,
job_tags: '',
}}
/>
);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
});
test('should not load Skip Tags', () => {
wrapper = mountWithContexts(
<JobDetail
job={{
...mockJobData,
skip_tags: '',
}}
/>
);
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
});
});

View File

@@ -163,6 +163,11 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
}
addEvents(onReadyEvents);
setOnReadyEvents([]);
if (isFollowModeEnabled) {
setTimeout(() => {
scrollToEnd();
}, 0);
}
}, [isTreeReady, onReadyEvents]); // eslint-disable-line react-hooks/exhaustive-deps
const totalNonCollapsedRows = Math.max(
@@ -188,9 +193,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
}, [location.search]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!isJobRunning(jobStatus)) {
setIsFollowModeEnabled(false);
}
rebuildEventsTree();
}, [isFlatMode]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -242,7 +244,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
if (data.group_name === `${job.type}_events`) {
batchedEvents.push(data);
clearTimeout(batchTimeout);
if (batchedEvents.length >= 25) {
if (batchedEvents.length >= 10) {
addBatchedEvents();
} else {
batchTimeout = setTimeout(addBatchedEvents, 500);
@@ -268,6 +270,12 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
};
}, [isJobRunning(jobStatus)]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (isFollowModeEnabled) {
scrollToEnd();
}
}, [wsEvents.length, isFollowModeEnabled]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (listRef.current?.recomputeRowHeights) {
listRef.current.recomputeRowHeights();
@@ -419,9 +427,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
};
const rowRenderer = ({ index, parent, key, style }) => {
if (listRef.current && isFollowModeEnabled) {
setTimeout(() => scrollToRow(remoteRowCount - 1), 0);
}
let event;
let node;
try {
@@ -568,6 +573,9 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
loadRange.forEach((n) => {
cache.clear(n);
});
if (isFollowModeEnabled) {
scrollToEnd();
}
};
const scrollToRow = (rowIndex) => {
@@ -580,14 +588,16 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
const handleScrollPrevious = () => {
const startIndex = listRef.current.Grid._renderedRowStartIndex;
const stopIndex = listRef.current.Grid._renderedRowStopIndex;
const scrollRange = stopIndex - startIndex + 1;
const scrollRange = stopIndex - startIndex;
scrollToRow(Math.max(0, startIndex - scrollRange));
setIsFollowModeEnabled(false);
};
const handleScrollNext = () => {
const startIndex = listRef.current.Grid._renderedRowStartIndex;
const stopIndex = listRef.current.Grid._renderedRowStopIndex;
scrollToRow(stopIndex - 1);
const scrollRange = stopIndex - startIndex;
scrollToRow(stopIndex + scrollRange);
};
const handleScrollFirst = () => {
@@ -595,8 +605,14 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
setIsFollowModeEnabled(false);
};
const scrollToEnd = () => {
scrollToRow(-1);
setTimeout(() => scrollToRow(-1), 100);
};
const handleScrollLast = () => {
scrollToRow(totalNonCollapsedRows + wsEvents.length);
scrollToEnd();
setIsFollowModeEnabled(true);
};
const handleResize = ({ width }) => {
@@ -619,6 +635,9 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
}
scrollTop.current = e.scrollTop;
scrollHeight.current = e.scrollHeight;
if (e.scrollTop + e.clientHeight >= e.scrollHeight) {
setIsFollowModeEnabled(true);
}
};
const handleExpandCollapseAll = () => {
@@ -658,8 +677,7 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
job={job}
eventRelatedSearchableKeys={eventRelatedSearchableKeys}
eventSearchableKeys={eventSearchableKeys}
remoteRowCount={remoteRowCount}
scrollToRow={scrollToRow}
scrollToEnd={scrollToEnd}
isFollowModeEnabled={isFollowModeEnabled}
setIsFollowModeEnabled={setIsFollowModeEnabled}
/>
@@ -718,7 +736,6 @@ function JobOutput({ job, eventRelatedSearchableKeys, eventSearchableKeys }) {
rowCount={totalNonCollapsedRows + wsEvents.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
scrollToAlignment="start"
width={width || 1}
overscanRowCount={20}
onScroll={handleScroll}

View File

@@ -30,8 +30,7 @@ function JobOutputSearch({
job,
eventRelatedSearchableKeys,
eventSearchableKeys,
remoteRowCount,
scrollToRow,
scrollToEnd,
isFollowModeEnabled,
setIsFollowModeEnabled,
}) {
@@ -83,7 +82,7 @@ function JobOutputSearch({
setIsFollowModeEnabled(false);
} else {
setIsFollowModeEnabled(true);
scrollToRow(remoteRowCount - 1);
scrollToEnd();
}
};

View File

@@ -30,7 +30,7 @@ function OrganizationDetail({ organization }) {
created,
modified,
summary_fields,
galaxy_credentials,
galaxy_credentials = [],
} = organization;
const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
@@ -121,7 +121,7 @@ function OrganizationDetail({ organization }) {
date={modified}
user={summary_fields.modified_by}
/>
{instanceGroups && instanceGroups.length > 0 && (
{instanceGroups && (
<Detail
fullWidth
label={t`Instance Groups`}
@@ -145,35 +145,35 @@ function OrganizationDetail({ organization }) {
))}
</ChipGroup>
}
isEmpty={instanceGroups.length === 0}
/>
)}
{galaxy_credentials && galaxy_credentials.length > 0 && (
<Detail
fullWidth
label={t`Galaxy Credentials`}
value={
<ChipGroup
numChips={5}
totalChips={galaxy_credentials.length}
ouiaId="galaxy-credential-chips"
>
{galaxy_credentials.map((credential) => (
<Link
<Detail
fullWidth
label={t`Galaxy Credentials`}
value={
<ChipGroup
numChips={5}
totalChips={galaxy_credentials?.length}
ouiaId="galaxy-credential-chips"
>
{galaxy_credentials?.map((credential) => (
<Link
key={credential.id}
to={`/credentials/${credential.id}/details`}
>
<CredentialChip
credential={credential}
key={credential.id}
to={`/credentials/${credential.id}/details`}
>
<CredentialChip
credential={credential}
key={credential.id}
isReadOnly
ouiaId={`galaxy-credential-${credential.id}-chip`}
/>
</Link>
))}
</ChipGroup>
}
/>
)}
isReadOnly
ouiaId={`galaxy-credential-${credential.id}-chip`}
/>
</Link>
))}
</ChipGroup>
}
isEmpty={galaxy_credentials?.length === 0}
/>
</DetailList>
<CardActionsRow>
{summary_fields.user_capabilities.edit && (

View File

@@ -216,4 +216,44 @@ describe('<OrganizationDetail />', () => {
(el) => el.length === 0
);
});
test('should not load instance groups', async () => {
OrganizationsAPI.readInstanceGroups.mockResolvedValue({
data: {
results: [],
},
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<OrganizationDetail organization={mockOrganization} />
);
});
wrapper.update();
const instance_groups_detail = wrapper
.find(`Detail[label="Instance Groups"]`)
.at(0);
expect(instance_groups_detail.prop('isEmpty')).toEqual(true);
});
test('should not load galaxy credentials', async () => {
OrganizationsAPI.readInstanceGroups.mockResolvedValue({ data: {} });
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<OrganizationDetail
organization={{
...mockOrganization,
credential: [],
}}
/>
);
});
wrapper.update();
const galaxy_credentials_detail = wrapper
.find(`Detail[label="Galaxy Credentials"]`)
.at(0);
expect(galaxy_credentials_detail.prop('isEmpty')).toEqual(true);
});
});

View File

@@ -35,6 +35,13 @@ function SubscriptionDetail() {
},
];
const { automated_instances: automatedInstancesCount, automated_since } =
license_info;
const automatedInstancesSinceDateTime = automated_since
? formatDateString(new Date(automated_since * 1000).toISOString())
: null;
return (
<>
<RoutedTabs tabsArray={tabsArray} />
@@ -127,19 +134,23 @@ function SubscriptionDetail() {
label={t`Hosts imported`}
value={license_info.current_instances}
/>
<Detail
dataCy="subscription-hosts-automated"
label={t`Hosts automated`}
value={
<>
{license_info.automated_instances} <Trans>since</Trans>{' '}
{license_info.automated_since &&
formatDateString(
new Date(license_info.automated_since * 1000).toISOString()
)}
</>
}
/>
{typeof automatedInstancesCount !== 'undefined' &&
automatedInstancesCount !== null && (
<Detail
dataCy="subscription-hosts-automated"
label={t`Hosts automated`}
value={
automated_since ? (
<Trans>
{automatedInstancesCount} since{' '}
{automatedInstancesSinceDateTime}
</Trans>
) : (
automatedInstancesCount
)
}
/>
)}
<Detail
dataCy="subscription-hosts-remaining"
label={t`Hosts remaining`}

View File

@@ -82,4 +82,17 @@ describe('<SubscriptionDetail />', () => {
expect(wrapper.find('Button[aria-label="edit"]').length).toBe(1);
});
test('should not render Hosts Automated Detail if license_info.automated_instances is undefined', () => {
wrapper = mountWithContexts(<SubscriptionDetail />, {
context: {
config: {
...config,
license_info: { ...config.license_info, automated_instances: null },
},
},
});
expect(wrapper.find(`Detail[label="Hosts automated"]`).length).toBe(0);
});
});

View File

@@ -354,7 +354,7 @@ function JobTemplateDetail({ template }) {
helpText={helpText.enabledOptions}
/>
)}
{summary_fields.credentials && summary_fields.credentials.length > 0 && (
{summary_fields.credentials && (
<Detail
fullWidth
label={t`Credentials`}
@@ -378,9 +378,10 @@ function JobTemplateDetail({ template }) {
))}
</ChipGroup>
}
isEmpty={summary_fields.credentials.length === 0}
/>
)}
{summary_fields.labels && summary_fields.labels.results.length > 0 && (
{summary_fields.labels && (
<Detail
fullWidth
label={t`Labels`}
@@ -399,36 +400,36 @@ function JobTemplateDetail({ template }) {
))}
</ChipGroup>
}
isEmpty={summary_fields.labels.results.length === 0}
/>
)}
{instanceGroups.length > 0 && (
<Detail
fullWidth
label={t`Instance Groups`}
dataCy="jt-detail-instance-groups"
helpText={helpText.instanceGroups}
value={
<ChipGroup
numChips={5}
totalChips={instanceGroups.length}
ouiaId="instance-group-chips"
>
{instanceGroups.map((ig) => (
<Link to={`${buildLinkURL(ig)}${ig.id}/details`} key={ig.id}>
<Chip
key={ig.id}
ouiaId={`instance-group-${ig.id}-chip`}
isReadOnly
>
{ig.name}
</Chip>
</Link>
))}
</ChipGroup>
}
/>
)}
{job_tags && job_tags.length > 0 && (
<Detail
fullWidth
label={t`Instance Groups`}
dataCy="jt-detail-instance-groups"
helpText={helpText.instanceGroups}
value={
<ChipGroup
numChips={5}
totalChips={instanceGroups.length}
ouiaId="instance-group-chips"
>
{instanceGroups.map((ig) => (
<Link to={`${buildLinkURL(ig)}${ig.id}/details`} key={ig.id}>
<Chip
key={ig.id}
ouiaId={`instance-group-${ig.id}-chip`}
isReadOnly
>
{ig.name}
</Chip>
</Link>
))}
</ChipGroup>
}
isEmpty={instanceGroups.length === 0}
/>
{job_tags && (
<Detail
fullWidth
label={t`Job Tags`}
@@ -451,9 +452,10 @@ function JobTemplateDetail({ template }) {
))}
</ChipGroup>
}
isEmpty={job_tags.length === 0}
/>
)}
{skip_tags && skip_tags.length > 0 && (
{skip_tags && (
<Detail
fullWidth
label={t`Skip Tags`}
@@ -476,6 +478,7 @@ function JobTemplateDetail({ template }) {
))}
</ChipGroup>
}
isEmpty={skip_tags.length === 0}
/>
)}
<VariablesDetail

View File

@@ -195,4 +195,94 @@ describe('<JobTemplateDetail />', () => {
wrapper.find(`Detail[label="Execution Environment"] dd`).text()
).toBe('Default EE');
});
test('should not load credentials', async () => {
await act(async () => {
wrapper = mountWithContexts(
<JobTemplateDetail
template={{
...mockTemplate,
allow_simultaneous: true,
ask_inventory_on_launch: true,
summary_fields: {
credentials: [],
},
}}
/>
);
});
const credentials_detail = wrapper
.find(`Detail[label="Credentials"]`)
.at(0);
expect(credentials_detail.prop('isEmpty')).toEqual(true);
});
test('should not load labels', async () => {
await act(async () => {
wrapper = mountWithContexts(
<JobTemplateDetail
template={{
...mockTemplate,
allow_simultaneous: true,
ask_inventory_on_launch: true,
summary_fields: {
labels: {
results: [],
},
},
}}
/>
);
});
const labels_detail = wrapper.find(`Detail[label="Labels"]`).at(0);
expect(labels_detail.prop('isEmpty')).toEqual(true);
});
test('should not load instance groups', async () => {
JobTemplatesAPI.readInstanceGroups.mockResolvedValue({
data: {
results: [],
},
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<JobTemplateDetail template={mockTemplate} />
);
});
wrapper.update();
const instance_groups_detail = wrapper
.find(`Detail[label="Instance Groups"]`)
.at(0);
expect(instance_groups_detail.prop('isEmpty')).toEqual(true);
});
test('should not load job tags', async () => {
await act(async () => {
wrapper = mountWithContexts(
<JobTemplateDetail
template={{
...mockTemplate,
job_tags: '',
}}
/>
);
});
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
});
test('should not load skip tags', async () => {
await act(async () => {
wrapper = mountWithContexts(
<JobTemplateDetail
template={{
...mockTemplate,
skip_tags: '',
}}
/>
);
});
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
});
});

View File

@@ -110,12 +110,11 @@ function WorkflowJobTemplateDetail({ template }) {
<DetailList gutter="sm">
<Detail label={t`Name`} value={name} dataCy="jt-detail-name" />
<Detail label={t`Description`} value={description} />
{summary_fields.recent_jobs?.length > 0 && (
<Detail
value={<Sparkline jobs={recentPlaybookJobs} />}
label={t`Activity`}
/>
)}
<Detail
value={<Sparkline jobs={recentPlaybookJobs} />}
label={t`Activity`}
isEmpty={summary_fields.recent_jobs?.length === 0}
/>
{summary_fields.organization && (
<Detail
label={t`Organization`}
@@ -202,26 +201,25 @@ function WorkflowJobTemplateDetail({ template }) {
helpText={helpText.enabledOptions}
/>
)}
{summary_fields.labels?.results?.length > 0 && (
<Detail
fullWidth
label={t`Labels`}
helpText={helpText.labels}
value={
<ChipGroup
numChips={3}
totalChips={summary_fields.labels.results.length}
ouiaId="workflow-job-template-detail-label-chips"
>
{summary_fields.labels.results.map((l) => (
<Chip key={l.id} ouiaId={`${l.name}-label-chip`} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
<Detail
fullWidth
label={t`Labels`}
helpText={helpText.labels}
value={
<ChipGroup
numChips={3}
totalChips={summary_fields.labels.results.length}
ouiaId="workflow-job-template-detail-label-chips"
>
{summary_fields.labels.results.map((l) => (
<Chip key={l.id} ouiaId={`${l.name}-label-chip`} isReadOnly>
{l.name}
</Chip>
))}
</ChipGroup>
}
isEmpty={!summary_fields.labels?.results?.length}
/>
<VariablesDetail
dataCy="workflow-job-template-detail-extra-vars"
helpText={helpText.variables}

View File

@@ -178,4 +178,46 @@ describe('<WorkflowJobTemplateDetail/>', () => {
expect(inventory.prop('to')).toEqual('/inventories/inventory/1/details');
expect(organization.prop('to')).toEqual('/organizations/1/details');
});
test('should not load Activity', async () => {
await act(async () => {
wrapper = mountWithContexts(
<WorkflowJobTemplateDetail
template={{
...template,
summary_fields: {
...template.summary_fields,
recent_jobs: [],
},
}}
hasContentLoading={false}
onSetContentLoading={() => {}}
/>
);
});
const activity_detail = wrapper.find(`Detail[label="Activity"]`).at(0);
expect(activity_detail.prop('isEmpty')).toEqual(true);
});
test('should not load Labels', async () => {
await act(async () => {
wrapper = mountWithContexts(
<WorkflowJobTemplateDetail
template={{
...template,
summary_fields: {
...template.summary_fields,
labels: {
results: [],
},
},
}}
hasContentLoading={false}
onSetContentLoading={() => {}}
/>
);
});
const labels_detail = wrapper.find(`Detail[label="Labels"]`).at(0);
expect(labels_detail.prop('isEmpty')).toEqual(true);
});
});

View File

@@ -44,30 +44,28 @@ function TemplatePopoverContent({ template }) {
value={template?.playbook}
dataCy={`template-${template.id}-playbook`}
/>
{template.summary_fields?.credentials &&
template.summary_fields.credentials.length ? (
<Detail
fullWidth
label={t`Credentials`}
dataCy={`template-${template.id}-credentials`}
value={
<ChipGroup
numChips={5}
totalChips={template.summary_fields.credentials.length}
ouiaId={`template-${template.id}-credential-chips`}
>
{template.summary_fields.credentials.map((c) => (
<CredentialChip
key={c.id}
credential={c}
isReadOnly
ouiaId={`credential-${c.id}-chip`}
/>
))}
</ChipGroup>
}
/>
) : null}
<Detail
fullWidth
label={t`Credentials`}
dataCy={`template-${template.id}-credentials`}
value={
<ChipGroup
numChips={5}
totalChips={template.summary_fields?.credentials?.length}
ouiaId={`template-${template.id}-credential-chips`}
>
{template.summary_fields?.credentials?.map((c) => (
<CredentialChip
key={c.id}
credential={c}
isReadOnly
ouiaId={`credential-${c.id}-chip`}
/>
))}
</ChipGroup>
}
isEmpty={template.summary_fields?.credentials?.length === 0}
/>
</DetailList>
);
}

View File

@@ -309,25 +309,24 @@ function WorkflowApprovalDetail({ workflowApproval }) {
dataCy="wa-detail-inventory"
/>
) : null}
{workflowJob?.summary_fields?.labels?.results?.length > 0 && (
<Detail
fullWidth
label={t`Labels`}
value={
<ChipGroup
numChips={5}
totalChips={workflowJob.summary_fields.labels.results.length}
ouiaId="wa-detail-label-chips"
>
{workflowJob.summary_fields.labels.results.map((label) => (
<Chip key={label.id} isReadOnly>
{label.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
<Detail
fullWidth
label={t`Labels`}
value={
<ChipGroup
numChips={5}
totalChips={workflowJob.summary_fields.labels.results.length}
ouiaId="wa-detail-label-chips"
>
{workflowJob.summary_fields.labels.results.map((label) => (
<Chip key={label.id} isReadOnly>
{label.name}
</Chip>
))}
</ChipGroup>
}
isEmpty={!workflowJob?.summary_fields?.labels?.results?.length}
/>
{workflowJob?.extra_vars ? (
<VariablesDetail
dataCy="wa-detail-variables"

View File

@@ -482,6 +482,33 @@ describe('<WorkflowApprovalDetail />', () => {
expect(wrapper.find('DeleteButton').length).toBe(1);
});
test('should not load Labels', async () => {
WorkflowJobTemplatesAPI.readDetail.mockResolvedValue({
data: workflowJobTemplate,
});
WorkflowJobsAPI.readDetail.mockResolvedValue({
data: {
...workflowApproval,
summary_fields: {
...workflowApproval.summary_fields,
labels: {
results: [],
},
},
},
});
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<WorkflowApprovalDetail workflowApproval={workflowApproval} />
);
});
waitForElement(wrapper, 'WorkflowApprovalDetail', (el) => el.length > 0);
const labels_detail = wrapper.find(`Detail[label="Labels"]`).at(0);
expect(labels_detail.prop('isEmpty')).toEqual(true);
});
test('Error dialog shown for failed approval', async () => {
WorkflowApprovalsAPI.approve.mockImplementationOnce(() =>
Promise.reject(new Error())

View File

@@ -1,9 +1,9 @@
/* eslint-disable no-undef */
importScripts('https://d3js.org/d3-collection.v1.min.js');
importScripts('https://d3js.org/d3-dispatch.v1.min.js');
importScripts('https://d3js.org/d3-quadtree.v1.min.js');
importScripts('https://d3js.org/d3-timer.v1.min.js');
importScripts('https://d3js.org/d3-force.v1.min.js');
importScripts('d3-collection.v1.min.js');
importScripts('d3-dispatch.v1.min.js');
importScripts('d3-quadtree.v1.min.js');
importScripts('d3-timer.v1.min.js');
importScripts('d3-force.v1.min.js');
onmessage = function calculateLayout({ data: { nodes, links } }) {
const simulation = d3

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