Compare commits

..

167 Commits
1.0.7 ... 1.0.8

Author SHA1 Message Date
Shane McDonald
580004b395 Merge remote-tracking branch 'downstream/release_3.3.0' into devel
# Conflicts:
#	awx/main/notifications/slack_backend.py
2018-09-18 08:11:50 -04:00
Ryan Petrello
910663764f Merge pull request #2979 from ryanpetrello/celery-init-race
consolidate celery init signals to avoid an instance registration race
2018-09-07 09:46:06 -04:00
Ryan Petrello
43aa0fc741 consolidate celery init signals to avoid an instance registration race 2018-09-07 09:29:53 -04:00
Jake McDermott
b9b9fc1934 Merge pull request #2974 from jakemcdermott/fix-2968
delete text nodes when removing output lines
2018-09-06 13:25:53 -04:00
Jake McDermott
093f453073 don't render playbook_on_start events 2018-09-06 13:17:39 -04:00
Jake McDermott
0e696d0515 add destroy hook to index controller 2018-09-06 13:17:30 -04:00
Jake McDermott
c4a29ded1c use el.contents() to remove lines 2018-09-06 13:17:20 -04:00
Bill Nottingham
f402ff0ee7 Merge pull request #2972 from wenottingham/awx-apache
Add a license file for awx, for completeness purposes.
2018-09-05 13:04:35 -04:00
Bill Nottingham
b982793a3a Add a license file for awx, for completeness purposes. 2018-09-05 11:19:41 -04:00
Shane McDonald
b3f2f7efe5 Fix broken file ref 2018-09-05 00:06:11 -04:00
kialam
19f9a3f918 Merge pull request #2970 from kialam/fix/2969-empty-stdout
Detect if `stdout` field is null or undefined.
2018-09-04 14:11:44 -04:00
Bill Nottingham
4b2e709e8d Merge pull request #2967 from wenottingham/legal-beagles
Remove stale licenses, fix some name typos.
2018-09-04 11:53:46 -04:00
kialam
146590d0c2 Detect if stdout field is null or undefined. 2018-09-04 11:05:13 -04:00
Bill Nottingham
247ee4ddac Remove stale licenses, fix some name typos. 2018-08-31 13:32:25 -04:00
Ryan Petrello
2f2294b65a Merge pull request #2966 from ryanpetrello/fix-2950
fix LicenseForbids 401/402 precendence for other features
2018-08-31 12:00:50 -04:00
Ryan Petrello
ec873dd28c fix LicenseForbids 401/402 precendence for other features 2018-08-31 11:23:11 -04:00
Ryan Petrello
c2bd36e580 Merge pull request #2965 from ryanpetrello/fix-2950
workflow endpoints should return 401 on invalid credentials
2018-08-31 11:16:18 -04:00
Ryan Petrello
1f8736ce1d workflow endpoints should return 401 on invalid credentials
if you have a license that doesn't allow use of workflows, invalid
credentials yielded an HTTP 402; this commit changes the precedence

see: https://github.com/ansible/tower/issues/2950
2018-08-31 10:57:27 -04:00
Alan Rominger
50a9f0be6b Merge pull request #2960 from AlanCoding/bad_locks
Put atomic block inside lock block
2018-08-30 14:53:01 -04:00
Ryan Petrello
8f3c5be04e Merge pull request #2962 from ryanpetrello/fix-2952
fix a subtle bug in awx.main.access.OAuth2ApplicationAccess.can_read
2018-08-30 14:42:28 -04:00
Ryan Petrello
34ceaf4551 fix a subtle bug in awx.main.access.OAuth2ApplicationAccess.can_read
see: https://github.com/ansible/tower/issues/2952
2018-08-30 14:21:03 -04:00
AlanCoding
786e907e3b put atomic block inside lock block 2018-08-30 12:54:53 -04:00
Ryan Petrello
c5aa9ee12b Merge pull request #2959 from ryanpetrello/fix-2957
don't access the database in our custom route_for_task
2018-08-30 11:54:32 -04:00
Ryan Petrello
24f8cb49b5 don't access the database in our custom route_for_task
If database connectivity is lost/interrupted in this block of celery
internals, beat is *not* smart enough to recover, and it gets stuck in
an endless fail loop.  We don't _need_ to talk to the database here
anyways; just use settings.CLUSTER_HOST_ID to get what we need.

see: https://github.com/ansible/tower/issues/2957
2018-08-30 11:40:43 -04:00
Jake McDermott
54d967af0d Merge pull request #2947 from jakemcdermott/job-results/colormap
add basic colormap for output lines
2018-08-30 11:40:37 -04:00
Ryan Petrello
3c91370cab Merge pull request #2955 from ryanpetrello/fix-2951
write custom inventory scripts to AWX_PRIVATE_DATA_DIR
2018-08-30 09:25:42 -04:00
Bill Nottingham
e22dc3dc7b Merge pull request #2941 from wenottingham/come-to-the-source
Update sources to match versions... add a few missing LGPL ones.
2018-08-30 09:21:17 -04:00
Ryan Petrello
9ae41dc3ba write custom inventory scripts to AWX_PRIVATE_DATA_DIR
this makes it so that custom inventory scripts can access credential
files laid down in `/tmp/awx_N_<xyz>`

see: https://github.com/ansible/tower/issues/2951
2018-08-30 08:32:27 -04:00
Alan Rominger
f175d6dfae Merge pull request #2931 from AlanCoding/more_licenses_yay
Add missing API licenses
2018-08-30 08:09:51 -04:00
Ryan Petrello
34c659d8b6 Merge pull request #2945 from ryanpetrello/improved-instance-list
make awx-manage instance_list easier to read and more useful
2018-08-29 15:11:38 -04:00
Ryan Petrello
6eb406ac39 make awx-manage instance_list easier to read and more useful 2018-08-29 15:00:05 -04:00
Jake McDermott
cddceb0e06 add basic colormap for output lines 2018-08-29 13:28:05 -04:00
John Mitchell
a549bea815 Merge pull request #2944 from jlmitch5/uiGetLicenseScriptUpdate
Ui get license script update
2018-08-29 12:14:27 -04:00
Jake McDermott
860fbdad02 Merge pull request #2880 from jakemcdermott/fix-2828
add event discard with interactive discontinuities for high volume jobs
2018-08-29 04:01:04 -04:00
Jake McDermott
f639e46718 advance ready counter by an entire batch when event limit is reached 2018-08-29 03:03:24 -04:00
Michael Abashian
56dc08683e Merge pull request #2943 from mabashian/2930-notif-admin-v2
Exposes organization notification list to users with notification admin role
2018-08-28 17:25:44 -04:00
mabashian
148daec49b Remove console.log 2018-08-28 16:14:53 -04:00
John Mitchell
2d03938451 remove dev dependencies from docs/licenses/ui folder 2018-08-28 15:49:57 -04:00
John Mitchell
bc7b586803 updated automated ui get license script to only look for nondev deps 2018-08-28 15:49:06 -04:00
mabashian
1408200927 Exposes organization notification list to users with notification admin role 2018-08-28 15:47:28 -04:00
Bill Nottingham
3576e192f4 Update sources to match versions... add a few missing LGPL ones. 2018-08-28 14:47:20 -04:00
Ryan Petrello
70d930e019 Merge pull request #2940 from ryanpetrello/more-i18n
more UI i18n
2018-08-28 13:57:24 -04:00
Ryan Petrello
c69e41b261 more UI i18n
see: https://github.com/ansible/tower/issues/1383
2018-08-28 12:47:18 -04:00
Michael Abashian
2949efd6ec Merge pull request #2939 from mabashian/2930-notif-admin
Handle notification admin user type in the UI
2018-08-28 11:22:46 -04:00
mabashian
2592613bde Fixes unit test failures 2018-08-28 11:01:04 -04:00
mabashian
de158cb41d Removes console.log 2018-08-27 17:33:35 -04:00
mabashian
f7737e2f94 Handle notification admin user type in the UI 2018-08-27 17:32:17 -04:00
Jake McDermott
4e45b6ba6d fix missing line styling 2018-08-27 17:17:48 -04:00
Ryan Petrello
5885654405 Merge pull request #2202 from rooftopcellist/pin_pluggy_awx
pin pluggy at 0.6.0
2018-08-27 17:15:29 -04:00
adamscmRH
227960e3ea pin pluggy at 0.6.0 2018-08-27 16:55:40 -04:00
Christian Adams
d6ba3e1fc2 Merge pull request #2938 from rooftopcellist/pin_pluggy
pin pluggy at 0.6.0
2018-08-27 16:46:55 -04:00
adamscmRH
2643a1b3d6 pin pluggy at 0.6.0 2018-08-27 16:13:24 -04:00
Ryan Petrello
6afb47789a Merge pull request #2936 from ryanpetrello/fix-1775
properly sanitize long event keys
2018-08-27 13:44:19 -04:00
Ryan Petrello
2acc488adf properly sanitize long event keys
see: https://github.com/ansible/tower/issues/1775
2018-08-27 13:40:26 -04:00
Michael Abashian
d0598e720d Merge pull request #2934 from mabashian/2933-search
Makes search filters additive again
2018-08-27 11:09:23 -04:00
Ryan Petrello
162ef08cef Merge pull request #2935 from mabashian/2873-first-last
Fixes bug removing first/last name from a user
2018-08-27 10:15:27 -04:00
Jake McDermott
aa0d2cff5c handle response data with discontinuities when using that data to fill other discontinuities 2018-08-27 01:38:02 -04:00
Jake McDermott
d608402dc1 refactor render service 2018-08-27 01:37:47 -04:00
Jake McDermott
04dbc2fcc4 add basic click handler for fetching and showing missing events 2018-08-27 01:37:36 -04:00
Jake McDermott
0bc9b1d431 render missing lines instead of auto-unfollowing 2018-08-27 01:37:28 -04:00
Jake McDermott
138f8a45ae moving render/record keeping and scroll functionality out of pagers 2018-08-27 01:37:18 -04:00
Jake McDermott
ee348b7169 add handling for discontinuities in render service 2018-08-27 01:37:11 -04:00
Jake McDermott
38b9b47e6b add max event count and discarding to stream service 2018-08-27 01:37:02 -04:00
Jake McDermott
2187655c68 move buffer mgmt to stream service 2018-08-27 01:36:52 -04:00
Jake McDermott
13203af353 Merge pull request #2921 from jakemcdermott/job-results/event-replay-skip-range
add option to job replay tool for skipping a range of job events
2018-08-25 22:54:46 -04:00
mabashian
4781df62ec Fixes bug removing first/last name from a user 2018-08-25 14:25:49 -04:00
mabashian
72372b3810 Makes search filters additive again 2018-08-25 14:00:24 -04:00
Alan Rominger
b742746e5d Merge pull request #2928 from AlanCoding/even_more_diff
Prefetch prior list of instances at start of policy calc task
2018-08-24 16:44:08 -04:00
AlanCoding
74fc0fef04 Manually pin reference list at start of pg_lock block 2018-08-24 15:28:28 -04:00
AlanCoding
bb8025c1af add missing API licenses 2018-08-24 15:16:56 -04:00
Michael Abashian
d824508cfb Merge pull request #2875 from mabashian/2868-lodash
Upgrades lodash to ~4.17.10
2018-08-24 13:27:07 -04:00
Ryan Petrello
077e541876 Merge pull request #2926 from ryanpetrello/deprecated-auth-token-helper
fix an auth-related typo in a docstring
2018-08-24 12:15:47 -04:00
Ryan Petrello
4561fd7270 fix an auth-related typo in a docstring 2018-08-24 11:56:11 -04:00
Ryan Petrello
50786f201f Merge pull request #2922 from ryanpetrello/deprecated-auth-token-helper
emulate /api/v2/authtoken/ to help customers transition to OAuth2.0
2018-08-24 11:40:47 -04:00
Ryan Petrello
5561eb30f7 emulate /api/v2/authtoken/ to help customers transition to OAuth2.0 2018-08-24 11:05:41 -04:00
Jake McDermott
e2c4fd5ebb add option for skipping counter slice range of events 2018-08-23 18:12:57 -04:00
John Mitchell
7226acb2b6 Merge pull request #2903 from jlmitch5/ui33Licenseifyer
add license grabbing script for ui deps
2018-08-23 16:53:12 -04:00
John Mitchell
7ef8e147f4 add license info about ui packages generated from script 2018-08-23 15:49:35 -04:00
John Mitchell
45db305e69 add script for generating ui license info in docs/licenses/ui 2018-08-23 15:49:01 -04:00
Ryan Petrello
52abb29091 Merge pull request #2919 from ryanpetrello/more-workflow-editor-i18n
sprinkle in more i18n translation for the workflow editor
2018-08-23 14:37:31 -04:00
Ryan Petrello
d564a268fd sprinkle in more i18n translation for the workflow editor
see: https://github.com/ansible/tower/issues/775
2018-08-23 14:07:15 -04:00
kialam
8280aff612 Merge pull request #2909 from kialam/fix/2836-part2
Fix Job Detail Stats Panel Title and Badge Whitespace with CSS modifier
2018-08-23 12:41:18 -04:00
Alan Rominger
b35d6b7425 Merge pull request #2911 from AlanCoding/mo_text
Document inventory script towervars
2018-08-23 09:18:36 -04:00
Ryan Petrello
7b692b0c31 Merge pull request #2913 from ryanpetrello/fix-2907
set the session cookie expiry *properly* on each request
2018-08-22 16:08:13 -04:00
Ryan Petrello
a271837007 set the session cookie expiry *properly* on each request
see: https://github.com/ansible/tower/issues/2907
2018-08-22 15:26:03 -04:00
AlanCoding
a3d0e10f51 remove added-in tags that reference old AWX 2018-08-22 15:17:47 -04:00
AlanCoding
5e8f7b76f1 document inventory script towervars 2018-08-22 15:16:39 -04:00
kialam
c67e9143fb Contain margins for stats panel with modifier
- Create CSS `—inline` modifier so that other areas where
`at-Panel-headingTitleBadge` are not affected.
2018-08-22 11:51:27 -04:00
Ryan Petrello
5abe045e6c Merge pull request #2908 from ryanpetrello/fix-2187
fix a bug that broke bot avatars for Slack notifications
2018-08-22 11:35:02 -04:00
Ryan Petrello
4bc63cc37e fix a bug that broke bot avatars for Slack notifications
when a *color* is specified for a Slack notification template, we use
the *web* not the RTM API; when you use a bot with the web API, you have
to specify the `as_user=True` argument to have the message use the bot's
name and avatar

see: https://github.com/ansible/tower/issues/2883
see: https://github.com/ansible/awx/issues/2187
2018-08-22 10:57:13 -04:00
Ryan Petrello
5cdd947196 Merge pull request #2870 from ryanpetrello/fix-2839
enforce 0 <= Instance.capacity_adjustment
2018-08-21 15:49:28 -04:00
Ryan Petrello
67d1267d98 enforce 0 <= Instance.capacity_adjustment
see: https://github.com/ansible/tower/issues/2839
2018-08-21 15:34:19 -04:00
Jake McDermott
66db615c0c Merge pull request #2888 from kialam/fix/2836
Adjust title and badge spacing on job output stats section.
2018-08-21 14:12:15 -04:00
Alan Rominger
598449c2ce Merge pull request #2882 from AlanCoding/just_credential2
[option2] move inventory source vault credential validation from view to model
2018-08-21 13:24:50 -04:00
kialam
4119c1dd0b Adjust title and badge spacing on job output stats section. 2018-08-21 10:51:31 -04:00
Ryan Petrello
2acf055f6a Merge pull request #2885 from ryanpetrello/fix-2874
apply sensitive field filtering to /api/v2/hosts/?host_filter
2018-08-21 10:43:52 -04:00
Ryan Petrello
4eeb62766e apply sensitive field filtering to /api/v2/hosts/?host_filter
see: https://github.com/ansible/tower/issues/2874
see: https://github.com/ansible/tower/issues/2889
2018-08-21 08:17:14 -04:00
Ryan Petrello
d995068396 Merge pull request #2895 from ryanpetrello/release_3.3.0
fix failing unit tests
2018-08-21 08:16:29 -04:00
Alan Rominger
ee139b306c Merge pull request #2881 from AlanCoding/log_cul_de_sacs
Change loggers from non-propagating to INFO filter
2018-08-21 07:36:52 -04:00
Ryan Petrello
a36b0061fa fix failing unit tests 2018-08-20 19:57:28 -04:00
Jake McDermott
eb0cf945cf Merge pull request #2860 from jakemcdermott/fix-2228
make line expand / collapse work for paginated scrollup
2018-08-20 13:12:52 -04:00
Jake McDermott
2e7ab57645 Merge pull request #2886 from jakemcdermott/job-results/line-search
enable output filtering by start / end line
2018-08-20 13:02:29 -04:00
Michael Abashian
d8f6c0aebc Merge pull request #2822 from mabashian/2819-prompt
Properly show prompt button when re-selecting a node with promptable fields
2018-08-20 12:45:38 -04:00
Jake McDermott
f8e5e38614 enable output filtering by start / end line 2018-08-20 12:40:27 -04:00
AlanCoding
3f841180da Change loggers from non-propagating to INFO filter 2018-08-20 09:02:55 -04:00
AlanCoding
9a85578925 move inv src vault cred validation from view to model 2018-08-20 08:53:55 -04:00
mabashian
f1e0c1e977 Upgrades lodash to ~4.17.10 2018-08-17 15:59:27 -04:00
Shane McDonald
1b8cb45024 Update translations 2018-08-17 13:56:34 -04:00
Bill Nottingham
fb9e508b6b Merge pull request #2857 from shanemcd/release_3.3.0
Fix / improve minishift dev env playbook
2018-08-17 13:19:24 -04:00
Ryan Petrello
0868f97335 Merge pull request #2866 from ryanpetrello/ci-for-missing-migrations
fail CI if the change includes model changes that are missing migrations
2018-08-17 08:41:46 -04:00
Ryan Petrello
30fbeb43bb fail CI if the change includes model changes that are missing migrations 2018-08-16 17:43:32 -04:00
mabashian
d2aea30d3d Add check for ask_variables_on_launch when determining whether to show prompt button 2018-08-16 15:41:15 -04:00
mabashian
cdb347cba5 Properly show prompt button when re-selecting a node with promptable fields 2018-08-16 15:41:15 -04:00
Ryan Petrello
c95c7c8b18 Merge pull request #2865 from ryanpetrello/fix-mystery-migrations
fix up remaining Django migrations
2018-08-16 15:03:36 -04:00
Ryan Petrello
14043f792a fix up remaining Django migrations
these don't really change anything in the schema; they just look like
Django ORM idiosyncrancies that `makemigrations` needs to be happy

see: https://github.com/ansible/tower/issues/2203
2018-08-16 13:59:06 -04:00
Christian Adams
9632f3b69e Merge pull request #2847 from rooftopcellist/fix_actstream_migration
Fix a variety of missing migrations
2018-08-16 13:32:53 -04:00
adamscmRH
da1da6f530 Fix oauth and std out mystery migrations 2018-08-16 13:08:45 -04:00
Jared Tabor
5b93007ba1 Merge pull request #2838 from jaredevantabor/fix-765
Checking for undefined default survey answers
2018-08-16 09:20:42 -07:00
Ryan Petrello
e87633f1d8 Merge pull request #2859 from ryanpetrello/shhhhhhhhhhhhhhhhhhhhhhhhhh
make inventory updates considerably less verbose by default
2018-08-16 09:34:14 -04:00
Jake McDermott
ca35eb39d2 make line expand / collapse work for paginated scrollup 2018-08-15 21:24:44 -04:00
Jared Tabor
5d84863237 Merge pull request #2856 from Haokun-Chen/2831
add max-height to job output console at breakpoint
2018-08-15 15:05:46 -07:00
Jared Tabor
f4728149d9 Changes max height of stdout panel for skinny browser widths 2018-08-15 14:44:03 -07:00
Ryan Petrello
4c7c8b6db3 make inventory updates considerably less verbose by default
see: https://github.com/ansible/tower/issues/2858
2018-08-15 16:04:15 -04:00
Ryan Petrello
db8ee2810a Merge pull request #2854 from ryanpetrello/more-custom-venv-help
provide friendlier help messages if you set up custom venvs wrong
2018-08-15 15:57:15 -04:00
Ryan Petrello
5ba8bbb08b Merge pull request #2855 from ryanpetrello/what-the-fork
close DB and cache sockets _immediately_ before we fork callback workers
2018-08-15 15:56:18 -04:00
Ryan Petrello
87adfe5889 close DB and cache sockets _immediately_ before we fork callback workers 2018-08-15 15:10:08 -04:00
Shane McDonald
07cb2aa9bb Fix / improve minishift dev env playbook
- Redo how we detect / set the minishift path
- Log into the correct admin account, once.
- Make sure commands that fail cause tasks to fail (s/;/&&/)
2018-08-15 15:01:40 -04:00
Shane McDonald
19c5564ec8 Update translation strings 2018-08-15 14:56:58 -04:00
Haokun-Chen
e05d071dab add max-height to job output console at breakpoint 2018-08-15 14:29:32 -04:00
Ryan Petrello
6ba1b170d2 provide friendlier help messages if you set up custom venvs wrong 2018-08-15 14:11:48 -04:00
Ryan Petrello
63d7abc7e4 Merge pull request #2853 from ryanpetrello/fix-2852
show a better error when a custom venv doesn't exist on an isolated node
2018-08-15 13:59:11 -04:00
Ryan Petrello
b318fa7814 Merge pull request #2851 from ryanpetrello/fix-2843
show custom_virtualenvs at /api/v2/config if you have Project/Org access
2018-08-15 13:40:44 -04:00
Ryan Petrello
5f6907ba83 show a better error when a custom venv doesn't exist on an isolated node
see: https://github.com/ansible/tower/issues/2852
2018-08-15 13:31:25 -04:00
Ryan Petrello
cffa324762 show custom_virtualenvs at /api/v2/config if you have Project/Org access
see: https://github.com/ansible/tower/issues/2843
2018-08-15 13:12:12 -04:00
Yunfan Zhang
b690e61576 Merge pull request #2849 from YunfanZhang42/release_3.3.0
Prevent implicit project updates from blocking jobs.
2018-08-15 12:13:11 -04:00
Shane McDonald
ae207b5f33 Merge pull request #2175 from shanemcd/kubernetes-fun
Pull in downstream k8s installer changes
2018-08-15 11:51:42 -04:00
Yunfan Zhang
5c23c63e6d Prevent implicit project updates from blocking jobs.
Signed-off-by: Yunfan Zhang <yz322@duke.edu>
2018-08-15 11:48:58 -04:00
Haokun Chen
3a133836dc Merge pull request #2833 from Haokun-Chen/2829
fixed build anchor for application in activity stream
2018-08-15 10:40:06 -04:00
Ryan Petrello
ab7cc88caf Merge pull request #2845 from ryanpetrello/remove-named-url-note
remove extraneous OPTIONS content re: the new named URL feature
2018-08-15 10:23:32 -04:00
Ryan Petrello
3b997cdd3a remove extraneous OPTIONS content re: the new named URL feature 2018-08-15 10:22:42 -04:00
Ryan Petrello
59f246d297 Merge pull request #2841 from ryanpetrello/indexes-for-new-events
add indexes for new events
2018-08-14 22:41:39 -04:00
Jake McDermott
14a8258835 Merge pull request #2842 from jakemcdermott/fix-2837
hide counter badges for output only jobs
2018-08-14 17:13:45 -04:00
Jake McDermott
897fb96f94 hide counter badges for output only jobs 2018-08-14 17:12:50 -04:00
Ryan Petrello
79a29ebcc8 add indexes for new event types
not sure why this didn't happen in the original migration that was
generated - may be related to differences in behavior across Django
versions?
2018-08-14 16:57:13 -04:00
Jared Tabor
950e4dab04 Checking for undefined default survey answers 2018-08-14 13:23:06 -07:00
Haokun-Chen
ab82cc3ba3 fixed build anchor for application in activity stream 2018-08-14 14:46:45 -04:00
Jared Tabor
93a8a952f1 Merge pull request #2806 from jaredevantabor/fix-2796
Fix 2796
2018-08-14 10:49:26 -07:00
Shane McDonald
2b9954c373 Pull in downstream k8s installer changes
- Secretification of secret stuff
- Backup / restore
2018-08-14 12:37:19 -04:00
Haokun Chen
21f0c1d1d7 Merge pull request #2832 from Haokun-Chen/fixed-sanitize-output
fixed
2018-08-14 12:09:52 -04:00
Haokun-Chen
379979511b fixed 2018-08-14 11:46:31 -04:00
Shane McDonald
2e6a7205e7 Fix broken conditional 2018-08-14 11:19:15 -04:00
Matthew Jones
14685901aa skip migrations If an environment variable is set
This is to help k8s/openshift migrations which will perform migrations
in a separate pod.
2018-08-14 11:00:51 -04:00
Haokun Chen
15480a56db Merge pull request #2820 from Haokun-Chen/2815
show client id and secret when create application
2018-08-13 16:30:34 -04:00
Haokun-Chen
9f54ba069e show client id and secret when create application 2018-08-13 15:54:57 -04:00
Christian Adams
03058cd1e8 Merge pull request #2824 from rooftopcellist/test_refresh_token
Test refresh token
2018-08-13 14:03:35 -04:00
Jake McDermott
a30c2fe227 Merge pull request #2817 from jakemcdermott/job-results/_debug-mode
add event replay mode to ui for finished jobs
2018-08-13 13:41:03 -04:00
Jake McDermott
d8e890b651 Merge pull request #2826 from jakemcdermott/fix-2818
fix handling for potentially missed events on initialization
2018-08-13 13:40:20 -04:00
Jake McDermott
95735ee01a Merge pull request #2827 from jakemcdermott/job-results/performance-testing
performance / ux scrolling fixes for higher volume jobs
2018-08-13 13:40:00 -04:00
adamscmRH
61931d0b6c add RefreshToken tests 2018-08-13 10:08:08 -04:00
Jake McDermott
516607551c show follow tip on first auto scroll hide 2018-08-12 19:35:59 -04:00
Jake McDermott
5e974d84b6 fix handling for missed events on initialization 2018-08-12 17:44:11 -04:00
Jake McDermott
91bc39be6b performance / ux improvements for higher volume jobs 2018-08-12 17:29:30 -04:00
kialam
49222d5e72 Merge pull request #2821 from kialam/fix/2797
Fix missing Prompt button for Extra Vars in WF Visualizer
2018-08-10 11:06:43 -07:00
kialam
686e5ac545 Handle extra vars case for "Prompt" button in WF visualizer. 2018-08-10 13:33:43 -04:00
Jake McDermott
0c3d6e7c33 add testing section for job events 2018-08-10 01:16:02 -04:00
Jake McDermott
e1b7e7f6ce add event replay mode 2018-08-10 01:15:41 -04:00
Jared Tabor
97c8005d00 Moves minimum idle time to 61 seconds (so that user can't type 60) 2018-08-08 17:09:04 -07:00
262 changed files with 11374 additions and 6745 deletions

View File

@@ -382,6 +382,7 @@ test:
. $(VENV_BASE)/awx/bin/activate; \
fi; \
py.test -n auto $(TEST_DIRS)
awx-manage check_migrations --dry-run --check -n 'vNNN_missing_migration_file'
test_combined: test_ansible test

View File

@@ -97,7 +97,7 @@ class DeprecatedCredentialField(serializers.IntegerField):
kwargs['allow_null'] = True
kwargs['default'] = None
kwargs['min_value'] = 1
kwargs['help_text'] = 'This resource has been deprecated and will be removed in a future release'
kwargs.setdefault('help_text', 'This resource has been deprecated and will be removed in a future release')
super(DeprecatedCredentialField, self).__init__(**kwargs)
def to_internal_value(self, pk):

View File

@@ -390,7 +390,6 @@ class GenericAPIView(generics.GenericAPIView, APIView):
]:
d[key] = self.metadata_class().get_serializer_info(serializer, method=method)
d['settings'] = settings
d['has_named_url'] = self.model in settings.NAMED_URL_GRAPH
return d

View File

@@ -1903,7 +1903,9 @@ class CustomInventoryScriptSerializer(BaseSerializer):
class InventorySourceOptionsSerializer(BaseSerializer):
credential = DeprecatedCredentialField()
credential = DeprecatedCredentialField(
help_text=_('Cloud credential to use for inventory updates.')
)
class Meta:
fields = ('*', 'source', 'source_path', 'source_script', 'source_vars', 'credential',

View File

@@ -54,8 +54,6 @@ within all designated text fields of a model.
?search=findme
_Added in AWX 1.4_
(_Added in Ansible Tower 3.1.0_) Search across related fields:
?related__search=findme
@@ -84,7 +82,7 @@ To exclude results matching certain criteria, prefix the field parameter with
?not__field=value
(_Added in AWX 1.4_) By default, all query string filters are AND'ed together, so
By default, all query string filters are AND'ed together, so
only the results matching *all* filters will be returned. To combine results
matching *any* one of multiple criteria, prefix each query string parameter
with `or__`:

View File

@@ -10,7 +10,7 @@ object containing groups, including the hosts, children and variables for each
group. The response data is equivalent to that returned by passing the
`--list` argument to an inventory script.
_(Added in AWX 1.3)_ Specify a query string of `?hostvars=1` to retrieve the JSON
Specify a query string of `?hostvars=1` to retrieve the JSON
object above including all host variables. The `['_meta']['hostvars']` object
in the response contains an entry for each host with its variables. This
response format can be used with Ansible 1.3 and later to avoid making a
@@ -18,11 +18,16 @@ separate API request for each host. Refer to
[Tuning the External Inventory Script](http://docs.ansible.com/developing_inventory.html#tuning-the-external-inventory-script)
for more information on this feature.
_(Added in AWX 1.4)_ By default, the inventory script will only return hosts that
By default, the inventory script will only return hosts that
are enabled in the inventory. This feature allows disabled hosts to be skipped
when running jobs without removing them from the inventory. Specify a query
string of `?all=1` to return all hosts, including disabled ones.
Specify a query string of `?towervars=1` to add variables
to the hostvars of each host that specifies its enabled state and database ID.
To apply multiple query strings, join them with the `&` character, like `?hostvars=1&all=1`.
## Host Response
Make a GET request to this resource with a query string similar to

View File

@@ -1,7 +1,3 @@
{% if has_named_url %}
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
# Retrieve {{ model_verbose_name|title|anora }}:
Make GET request to this resource to retrieve a single {{ model_verbose_name }}

View File

@@ -1,7 +1,3 @@
{% if has_named_url %}
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
{% ifmeth GET %}
# Retrieve {{ model_verbose_name|title|anora }}:

View File

@@ -1,7 +1,3 @@
{% if has_named_url %}
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
{% ifmeth GET %}
# Retrieve {{ model_verbose_name|title|anora }}:

View File

@@ -1,7 +1,3 @@
{% if has_named_url %}
### Note: starting from api v2, this resource object can be accessed via its named URL.
{% endif %}
{% ifmeth GET %}
# Retrieve {{ model_verbose_name|title|anora }}:

View File

@@ -115,9 +115,10 @@ class ActivityStreamEnforcementMixin(object):
Mixin to check that license supports activity streams.
'''
def check_permissions(self, request):
ret = super(ActivityStreamEnforcementMixin, self).check_permissions(request)
if not feature_enabled('activity_streams'):
raise LicenseForbids(_('Your license does not allow use of the activity stream.'))
return super(ActivityStreamEnforcementMixin, self).check_permissions(request)
return ret
class SystemTrackingEnforcementMixin(object):
@@ -125,9 +126,10 @@ class SystemTrackingEnforcementMixin(object):
Mixin to check that license supports system tracking.
'''
def check_permissions(self, request):
ret = super(SystemTrackingEnforcementMixin, self).check_permissions(request)
if not feature_enabled('system_tracking'):
raise LicenseForbids(_('Your license does not permit use of system tracking.'))
return super(SystemTrackingEnforcementMixin, self).check_permissions(request)
return ret
class WorkflowsEnforcementMixin(object):
@@ -135,9 +137,10 @@ class WorkflowsEnforcementMixin(object):
Mixin to check that license supports workflows.
'''
def check_permissions(self, request):
ret = super(WorkflowsEnforcementMixin, self).check_permissions(request)
if not feature_enabled('workflows') and request.method not in ('GET', 'OPTIONS', 'DELETE'):
raise LicenseForbids(_('Your license does not allow use of workflows.'))
return super(WorkflowsEnforcementMixin, self).check_permissions(request)
return ret
class UnifiedJobDeletionMixin(object):
@@ -442,9 +445,9 @@ class ApiV1ConfigView(APIView):
data.update(dict(
project_base_dir = settings.PROJECTS_ROOT,
project_local_paths = Project.get_local_path_choices(),
custom_virtualenvs = get_custom_venv_choices()
))
if JobTemplate.accessible_objects(request.user, 'admin_role').exists():
elif JobTemplate.accessible_objects(request.user, 'admin_role').exists():
data['custom_virtualenvs'] = get_custom_venv_choices()
return Response(data)
@@ -2883,17 +2886,14 @@ class InventorySourceCredentialsList(SubListAttachDetachAPIView):
relationship = 'credentials'
def is_valid_relation(self, parent, sub, created=False):
# Inventory source credentials are exclusive with all other credentials
# subject to change for https://github.com/ansible/awx/issues/277
# or https://github.com/ansible/awx/issues/223
if parent.credentials.exists():
return {'msg': _("Source already has credential assigned.")}
error = InventorySource.cloud_credential_validation(parent.source, sub)
if error:
return {'msg': error}
if sub.credential_type == 'vault':
# TODO: support this
return {"msg": _("Vault credentials are not yet supported for inventory sources.")}
else:
# Cloud credentials are exclusive with all other cloud credentials
cloud_cred_qs = parent.credentials.exclude(credential_type__kind='vault')
if cloud_cred_qs.exists():
return {'msg': _("Source already has cloud credential assigned.")}
return None

View File

@@ -24,7 +24,12 @@ import os
import pwd
# PSUtil
import psutil
try:
import psutil
except ImportError:
raise ImportError('psutil is missing; {}bin/pip install psutil'.format(
os.environ['VIRTUAL_ENV']
))
__all__ = []

View File

@@ -27,7 +27,13 @@ import os
import stat
import threading
import uuid
import memcache
try:
import memcache
except ImportError:
raise ImportError('python-memcached is missing; {}bin/pip install python-memcached'.format(
os.environ['VIRTUAL_ENV']
))
from six.moves import xrange

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-08-03 19:04+0000\n"
"POT-Creation-Date: 2018-08-14 13:52+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -125,15 +125,15 @@ msgid ""
"our REST API, the Content-Type must be application/json"
msgstr ""
#: awx/api/generics.py:629 awx/api/generics.py:691
#: awx/api/generics.py:635 awx/api/generics.py:697
msgid "\"id\" field must be an integer."
msgstr ""
#: awx/api/generics.py:688
#: awx/api/generics.py:694
msgid "\"id\" is required to disassociate"
msgstr ""
#: awx/api/generics.py:739
#: awx/api/generics.py:745
msgid "{} 'id' field is missing."
msgstr ""
@@ -1642,86 +1642,86 @@ msgstr ""
msgid "Bad data found in related field %s."
msgstr ""
#: awx/main/access.py:304
#: awx/main/access.py:302
msgid "License is missing."
msgstr ""
#: awx/main/access.py:306
#: awx/main/access.py:304
msgid "License has expired."
msgstr ""
#: awx/main/access.py:314
#: awx/main/access.py:312
#, python-format
msgid "License count of %s instances has been reached."
msgstr ""
#: awx/main/access.py:316
#: awx/main/access.py:314
#, python-format
msgid "License count of %s instances has been exceeded."
msgstr ""
#: awx/main/access.py:318
#: awx/main/access.py:316
msgid "Host count exceeds available instances."
msgstr ""
#: awx/main/access.py:322
#: awx/main/access.py:320
#, python-format
msgid "Feature %s is not enabled in the active license."
msgstr ""
#: awx/main/access.py:324
#: awx/main/access.py:322
msgid "Features not found in active license."
msgstr ""
#: awx/main/access.py:837
#: awx/main/access.py:835
msgid "Unable to change inventory on a host."
msgstr ""
#: awx/main/access.py:854 awx/main/access.py:899
#: awx/main/access.py:852 awx/main/access.py:897
msgid "Cannot associate two items from different inventories."
msgstr ""
#: awx/main/access.py:887
#: awx/main/access.py:885
msgid "Unable to change inventory on a group."
msgstr ""
#: awx/main/access.py:1148
#: awx/main/access.py:1146
msgid "Unable to change organization on a team."
msgstr ""
#: awx/main/access.py:1165
#: awx/main/access.py:1163
msgid "The {} role cannot be assigned to a team"
msgstr ""
#: awx/main/access.py:1167
#: awx/main/access.py:1165
msgid "The admin_role for a User cannot be assigned to a team"
msgstr ""
#: awx/main/access.py:1533 awx/main/access.py:1967
#: awx/main/access.py:1531 awx/main/access.py:1965
msgid "Job was launched with prompts provided by another user."
msgstr ""
#: awx/main/access.py:1553
#: awx/main/access.py:1551
msgid "Job has been orphaned from its job template."
msgstr ""
#: awx/main/access.py:1555
#: awx/main/access.py:1553
msgid "Job was launched with unknown prompted fields."
msgstr ""
#: awx/main/access.py:1557
#: awx/main/access.py:1555
msgid "Job was launched with prompted fields."
msgstr ""
#: awx/main/access.py:1559
#: awx/main/access.py:1557
msgid " Organization level permissions required."
msgstr ""
#: awx/main/access.py:1561
#: awx/main/access.py:1559
msgid " You do not have permission to related resources."
msgstr ""
#: awx/main/access.py:1981
#: awx/main/access.py:1979
msgid ""
"You do not have permission to the workflow job resources required for "
"relaunch."

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

File diff suppressed because it is too large Load Diff

View File

@@ -611,7 +611,8 @@ class OAuth2ApplicationAccess(BaseAccess):
select_related = ('user',)
def filtered_queryset(self):
return self.model.objects.filter(organization__in=self.user.organizations)
org_access_qs = Organization.accessible_objects(self.user, 'member_role')
return self.model.objects.filter(organization__in=org_access_qs)
def can_change(self, obj, data):
return self.user.is_superuser or self.check_related('organization', Organization, data, obj=obj,

View File

@@ -117,10 +117,10 @@ class IsolatedManager(object):
@classmethod
def awx_playbook_path(cls):
return os.path.join(
return os.path.abspath(os.path.join(
os.path.dirname(awx.__file__),
'playbooks'
)
))
def path_to(self, *args):
return os.path.join(self.private_data_dir, *args)

View File

@@ -208,6 +208,12 @@ def run_isolated_job(private_data_dir, secrets, logfile=sys.stdout):
env['AWX_ISOLATED_DATA_DIR'] = private_data_dir
env['PYTHONPATH'] = env.get('PYTHONPATH', '') + callback_dir + ':'
venv_path = env.get('VIRTUAL_ENV')
if venv_path and not os.path.exists(venv_path):
raise RuntimeError(
'a valid Python virtualenv does not exist at {}'.format(venv_path)
)
return run_pexpect(args, cwd, env, logfile,
expect_passwords=expect_passwords,
idle_timeout=idle_timeout,

View File

@@ -0,0 +1,12 @@
from django.db import connections
from django.db.backends.sqlite3.base import DatabaseWrapper
from django.core.management.commands.makemigrations import Command as MakeMigrations
class Command(MakeMigrations):
def execute(self, *args, **options):
settings = connections['default'].settings_dict.copy()
settings['ENGINE'] = 'sqlite3'
connections['default'] = DatabaseWrapper(settings)
return MakeMigrations().execute(*args, **options)

View File

@@ -491,7 +491,7 @@ class Command(BaseCommand):
for host in hosts_qs.filter(pk__in=del_pks):
host_name = host.name
host.delete()
logger.info('Deleted host "%s"', host_name)
logger.debug('Deleted host "%s"', host_name)
if settings.SQL_DEBUG:
logger.warning('host deletions took %d queries for %d hosts',
len(connection.queries) - queries_before,
@@ -528,7 +528,7 @@ class Command(BaseCommand):
group_name = group.name
with ignore_inventory_computed_fields():
group.delete()
logger.info('Group "%s" deleted', group_name)
logger.debug('Group "%s" deleted', group_name)
if settings.SQL_DEBUG:
logger.warning('group deletions took %d queries for %d groups',
len(connection.queries) - queries_before,
@@ -549,7 +549,7 @@ class Command(BaseCommand):
db_groups = self.inventory_source.groups
for db_group in db_groups.all():
if self.inventory_source.deprecated_group_id == db_group.id: # TODO: remove in 3.3
logger.info(
logger.debug(
'Group "%s" from v1 API child group/host connections preserved',
db_group.name
)
@@ -566,8 +566,8 @@ class Command(BaseCommand):
for db_child in db_children.filter(pk__in=child_group_pks):
group_group_count += 1
db_group.children.remove(db_child)
logger.info('Group "%s" removed from group "%s"',
db_child.name, db_group.name)
logger.debug('Group "%s" removed from group "%s"',
db_child.name, db_group.name)
# FIXME: Inventory source group relationships
# Delete group/host relationships not present in imported data.
db_hosts = db_group.hosts
@@ -594,8 +594,8 @@ class Command(BaseCommand):
if db_host not in db_group.hosts.all():
continue
db_group.hosts.remove(db_host)
logger.info('Host "%s" removed from group "%s"',
db_host.name, db_group.name)
logger.debug('Host "%s" removed from group "%s"',
db_host.name, db_group.name)
if settings.SQL_DEBUG:
logger.warning('group-group and group-host deletions took %d queries for %d relationships',
len(connection.queries) - queries_before,
@@ -614,9 +614,9 @@ class Command(BaseCommand):
if db_variables != all_obj.variables_dict:
all_obj.variables = json.dumps(db_variables)
all_obj.save(update_fields=['variables'])
logger.info('Inventory variables updated from "all" group')
logger.debug('Inventory variables updated from "all" group')
else:
logger.info('Inventory variables unmodified')
logger.debug('Inventory variables unmodified')
def _create_update_groups(self):
'''
@@ -648,11 +648,11 @@ class Command(BaseCommand):
group.variables = json.dumps(db_variables)
group.save(update_fields=['variables'])
if self.overwrite_vars:
logger.info('Group "%s" variables replaced', group.name)
logger.debug('Group "%s" variables replaced', group.name)
else:
logger.info('Group "%s" variables updated', group.name)
logger.debug('Group "%s" variables updated', group.name)
else:
logger.info('Group "%s" variables unmodified', group.name)
logger.debug('Group "%s" variables unmodified', group.name)
existing_group_names.add(group.name)
self._batch_add_m2m(self.inventory_source.groups, group)
for group_name in all_group_names:
@@ -666,7 +666,7 @@ class Command(BaseCommand):
'description':'imported'
}
)[0]
logger.info('Group "%s" added', group.name)
logger.debug('Group "%s" added', group.name)
self._batch_add_m2m(self.inventory_source.groups, group)
self._batch_add_m2m(self.inventory_source.groups, flush=True)
if settings.SQL_DEBUG:
@@ -705,24 +705,24 @@ class Command(BaseCommand):
if update_fields:
db_host.save(update_fields=update_fields)
if 'name' in update_fields:
logger.info('Host renamed from "%s" to "%s"', old_name, mem_host.name)
logger.debug('Host renamed from "%s" to "%s"', old_name, mem_host.name)
if 'instance_id' in update_fields:
if old_instance_id:
logger.info('Host "%s" instance_id updated', mem_host.name)
logger.debug('Host "%s" instance_id updated', mem_host.name)
else:
logger.info('Host "%s" instance_id added', mem_host.name)
logger.debug('Host "%s" instance_id added', mem_host.name)
if 'variables' in update_fields:
if self.overwrite_vars:
logger.info('Host "%s" variables replaced', mem_host.name)
logger.debug('Host "%s" variables replaced', mem_host.name)
else:
logger.info('Host "%s" variables updated', mem_host.name)
logger.debug('Host "%s" variables updated', mem_host.name)
else:
logger.info('Host "%s" variables unmodified', mem_host.name)
logger.debug('Host "%s" variables unmodified', mem_host.name)
if 'enabled' in update_fields:
if enabled:
logger.info('Host "%s" is now enabled', mem_host.name)
logger.debug('Host "%s" is now enabled', mem_host.name)
else:
logger.info('Host "%s" is now disabled', mem_host.name)
logger.debug('Host "%s" is now disabled', mem_host.name)
self._batch_add_m2m(self.inventory_source.hosts, db_host)
def _create_update_hosts(self):
@@ -796,9 +796,9 @@ class Command(BaseCommand):
host_attrs['instance_id'] = instance_id
db_host = self.inventory.hosts.update_or_create(name=mem_host_name, defaults=host_attrs)[0]
if enabled is False:
logger.info('Host "%s" added (disabled)', mem_host_name)
logger.debug('Host "%s" added (disabled)', mem_host_name)
else:
logger.info('Host "%s" added', mem_host_name)
logger.debug('Host "%s" added', mem_host_name)
self._batch_add_m2m(self.inventory_source.hosts, db_host)
self._batch_add_m2m(self.inventory_source.hosts, flush=True)
@@ -827,10 +827,10 @@ class Command(BaseCommand):
child_names = all_child_names[offset2:(offset2 + self._batch_size)]
db_children_qs = self.inventory.groups.filter(name__in=child_names)
for db_child in db_children_qs.filter(children__id=db_group.id):
logger.info('Group "%s" already child of group "%s"', db_child.name, db_group.name)
logger.debug('Group "%s" already child of group "%s"', db_child.name, db_group.name)
for db_child in db_children_qs.exclude(children__id=db_group.id):
self._batch_add_m2m(db_group.children, db_child)
logger.info('Group "%s" added as child of "%s"', db_child.name, db_group.name)
logger.debug('Group "%s" added as child of "%s"', db_child.name, db_group.name)
self._batch_add_m2m(db_group.children, flush=True)
if settings.SQL_DEBUG:
logger.warning('Group-group updates took %d queries for %d group-group relationships',
@@ -854,19 +854,19 @@ class Command(BaseCommand):
host_names = all_host_names[offset2:(offset2 + self._batch_size)]
db_hosts_qs = self.inventory.hosts.filter(name__in=host_names)
for db_host in db_hosts_qs.filter(groups__id=db_group.id):
logger.info('Host "%s" already in group "%s"', db_host.name, db_group.name)
logger.debug('Host "%s" already in group "%s"', db_host.name, db_group.name)
for db_host in db_hosts_qs.exclude(groups__id=db_group.id):
self._batch_add_m2m(db_group.hosts, db_host)
logger.info('Host "%s" added to group "%s"', db_host.name, db_group.name)
logger.debug('Host "%s" added to group "%s"', db_host.name, db_group.name)
all_instance_ids = sorted([h.instance_id for h in mem_group.hosts if h.instance_id])
for offset2 in xrange(0, len(all_instance_ids), self._batch_size):
instance_ids = all_instance_ids[offset2:(offset2 + self._batch_size)]
db_hosts_qs = self.inventory.hosts.filter(instance_id__in=instance_ids)
for db_host in db_hosts_qs.filter(groups__id=db_group.id):
logger.info('Host "%s" already in group "%s"', db_host.name, db_group.name)
logger.debug('Host "%s" already in group "%s"', db_host.name, db_group.name)
for db_host in db_hosts_qs.exclude(groups__id=db_group.id):
self._batch_add_m2m(db_group.hosts, db_host)
logger.info('Host "%s" added to group "%s"', db_host.name, db_group.name)
logger.debug('Host "%s" added to group "%s"', db_host.name, db_group.name)
self._batch_add_m2m(db_group.hosts, flush=True)
if settings.SQL_DEBUG:
logger.warning('Group-host updates took %d queries for %d group-host relationships',

View File

@@ -6,6 +6,22 @@ from django.core.management.base import BaseCommand
import six
class Ungrouped(object):
name = 'ungrouped'
policy_instance_percentage = None
policy_instance_minimum = None
controller = None
@property
def instances(self):
return Instance.objects.filter(rampart_groups__isnull=True)
@property
def capacity(self):
return sum([x.capacity for x in self.instances])
class Command(BaseCommand):
"""List instances from the Tower database
"""
@@ -13,12 +29,28 @@ class Command(BaseCommand):
def handle(self, *args, **options):
super(Command, self).__init__()
for instance in Instance.objects.all():
print(six.text_type(
"hostname: {0.hostname}; created: {0.created}; "
"heartbeat: {0.modified}; capacity: {0.capacity}").format(instance))
for instance_group in InstanceGroup.objects.all():
print(six.text_type(
"Instance Group: {0.name}; created: {0.created}; "
"capacity: {0.capacity}; members: {1}").format(instance_group,
[x.hostname for x in instance_group.instances.all()]))
groups = list(InstanceGroup.objects.all())
ungrouped = Ungrouped()
if len(ungrouped.instances):
groups.append(ungrouped)
for instance_group in groups:
fmt = '[{0.name} capacity={0.capacity}'
if instance_group.policy_instance_percentage:
fmt += ' policy={0.policy_instance_percentage}%'
if instance_group.policy_instance_minimum:
fmt += ' policy>={0.policy_instance_minimum}'
if instance_group.controller:
fmt += ' controller={0.controller.name}'
print(six.text_type(fmt + ']').format(instance_group))
for x in instance_group.instances.all():
color = '\033[92m'
if x.capacity == 0 or x.enabled is False:
color = '\033[91m'
fmt = '\t' + color + '{0.hostname} capacity={0.capacity} version={1}'
if x.last_isolated_check:
fmt += ' last_isolated_check="{0.last_isolated_check:%Y-%m-%d %H:%M:%S}"'
if x.capacity:
fmt += ' heartbeat="{0.modified:%Y-%m-%d %H:%M:%S}"'
print(six.text_type(fmt + '\033[0m').format(x, x.version or '?'))
print('')

View File

@@ -95,7 +95,7 @@ class ReplayJobEvents():
raise RuntimeError("Job is of type {} and replay is not yet supported.".format(type(job)))
sys.exit(1)
def run(self, job_id, speed=1.0, verbosity=0, skip=0):
def run(self, job_id, speed=1.0, verbosity=0, skip_range=[]):
stats = {
'events_ontime': {
'total': 0,
@@ -127,7 +127,7 @@ class ReplayJobEvents():
je_previous = None
for n, je_current in enumerate(job_events):
if n < skip:
if je_current.counter in skip_range:
continue
if not je_previous:
@@ -193,19 +193,29 @@ class Command(BaseCommand):
help = 'Replay job events over websockets ordered by created on date.'
def _parse_slice_range(self, slice_arg):
slice_arg = tuple([int(n) for n in slice_arg.split(':')])
slice_obj = slice(*slice_arg)
start = slice_obj.start or 0
stop = slice_obj.stop or -1
step = slice_obj.step or 1
return range(start, stop, step)
def add_arguments(self, parser):
parser.add_argument('--job_id', dest='job_id', type=int, metavar='j',
help='Id of the job to replay (job or adhoc)')
parser.add_argument('--speed', dest='speed', type=int, metavar='s',
help='Speedup factor.')
parser.add_argument('--skip', dest='skip', type=int, metavar='k',
help='Number of events to skip.')
parser.add_argument('--skip-range', dest='skip_range', type=str, metavar='k',
default='0:-1:1', help='Range of events to skip')
def handle(self, *args, **options):
job_id = options.get('job_id')
speed = options.get('speed') or 1
verbosity = options.get('verbosity') or 0
skip = options.get('skip') or 0
skip = self._parse_slice_range(options.get('skip_range'))
replayer = ReplayJobEvents()
replayer.run(job_id, speed, verbosity, skip)

View File

@@ -64,15 +64,22 @@ class CallbackBrokerWorker(ConsumerMixin):
return _handler
if use_workers:
django_connection.close()
django_cache.close()
for idx in range(settings.JOB_EVENT_WORKERS):
queue_actual = MPQueue(settings.JOB_EVENT_MAX_QUEUE_SIZE)
w = Process(target=self.callback_worker, args=(queue_actual, idx,))
w.start()
if settings.DEBUG:
logger.info('Started worker %s' % str(idx))
logger.info('Starting worker %s' % str(idx))
self.worker_queues.append([0, queue_actual, w])
# It's important to close these _right before_ we fork; we
# don't want the forked processes to inherit the open sockets
# for the DB and memcached connections (that way lies race
# conditions)
django_connection.close()
django_cache.close()
for _, _, w in self.worker_queues:
w.start()
elif settings.DEBUG:
logger.warn('Started callback receiver (no workers)')

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
import base64
import json
import logging
import threading
import uuid
@@ -9,12 +11,15 @@ import time
import cProfile
import pstats
import os
import re
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.signals import post_save
from django.db.migrations.executor import MigrationExecutor
from django.db import IntegrityError, connection
from django.http import HttpResponse
from django.utils.functional import curry
from django.shortcuts import get_object_or_404, redirect
from django.apps import apps
@@ -128,8 +133,9 @@ class SessionTimeoutMiddleware(object):
def process_response(self, request, response):
req_session = getattr(request, 'session', None)
if req_session and not req_session.is_empty():
request.session.set_expiry(request.session.get_expiry_age())
response['Session-Timeout'] = int(settings.SESSION_COOKIE_AGE)
expiry = int(settings.SESSION_COOKIE_AGE)
request.session.set_expiry(expiry)
response['Session-Timeout'] = expiry
return response
@@ -203,6 +209,56 @@ class URLModificationMiddleware(object):
request.path_info = new_path
class DeprecatedAuthTokenMiddleware(object):
"""
Used to emulate support for the old Auth Token endpoint to ease the
transition to OAuth2.0. Specifically, this middleware:
1. Intercepts POST requests to `/api/v2/authtoken/` (which now no longer
_actually_ exists in our urls.py)
2. Rewrites `request.path` to `/api/v2/users/N/personal_tokens/`
3. Detects the username and password in the request body (either in JSON,
or form-encoded variables) and builds an appropriate HTTP_AUTHORIZATION
Basic header
"""
def process_request(self, request):
if re.match('^/api/v[12]/authtoken/?$', request.path):
if request.method != 'POST':
return HttpResponse('HTTP {} is not allowed.'.format(request.method), status=405)
try:
payload = json.loads(request.body)
except (ValueError, TypeError):
payload = request.POST
if 'username' not in payload or 'password' not in payload:
return HttpResponse('Unable to login with provided credentials.', status=401)
username = payload['username']
password = payload['password']
try:
pk = User.objects.get(username=username).pk
except ObjectDoesNotExist:
return HttpResponse('Unable to login with provided credentials.', status=401)
new_path = reverse('api:user_personal_token_list', kwargs={
'pk': pk,
'version': 'v2'
})
request._body = ''
request.META['CONTENT_TYPE'] = 'application/json'
request.path = request.path_info = new_path
auth = ' '.join([
'Basic',
base64.b64encode(
six.text_type('{}:{}').format(username, password)
)
])
request.environ['HTTP_AUTHORIZATION'] = auth
logger.warn(
'The Auth Token API (/api/v2/authtoken/) is deprecated and will '
'be replaced with OAuth2.0 in the next version of Ansible Tower '
'(see /api/o/ for more details).'
)
class MigrationRanCheckMiddleware(object):
def process_request(self, request):

View File

@@ -157,7 +157,7 @@ class Migration(migrations.Migration):
('status', models.CharField(default=b'pending', max_length=20, editable=False, choices=[(b'pending', 'Pending'), (b'successful', 'Successful'), (b'failed', 'Failed')])),
('error', models.TextField(default=b'', editable=False, blank=True)),
('notifications_sent', models.IntegerField(default=0, editable=False)),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'irc', 'IRC')])),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'rocketchat', 'Rocket.Chat'), (b'irc', 'IRC')])),
('recipients', models.TextField(default=b'', editable=False, blank=True)),
('subject', models.TextField(default=b'', editable=False, blank=True)),
('body', jsonfield.fields.JSONField(default=dict, blank=True)),
@@ -174,7 +174,7 @@ class Migration(migrations.Migration):
('modified', models.DateTimeField(default=None, editable=False)),
('description', models.TextField(default=b'', blank=True)),
('name', models.CharField(unique=True, max_length=512)),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'irc', 'IRC')])),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'rocketchat', 'Rocket.Chat'), (b'irc', 'IRC')])),
('notification_configuration', jsonfield.fields.JSONField(default=dict)),
('created_by', models.ForeignKey(related_name="{u'class': 'notificationtemplate', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)),
('modified_by', models.ForeignKey(related_name="{u'class': 'notificationtemplate', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)),

View File

@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_line', models.PositiveIntegerField(default=0, editable=False)),
('end_line', models.PositiveIntegerField(default=0, editable=False)),
('inventory_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='generic_command_events', to='main.InventoryUpdate')),
('inventory_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_update_events', to='main.InventoryUpdate')),
],
options={
'ordering': ('-pk',),
@@ -53,7 +53,7 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_line', models.PositiveIntegerField(default=0, editable=False)),
('end_line', models.PositiveIntegerField(default=0, editable=False)),
('project_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='generic_command_events', to='main.ProjectUpdate')),
('project_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='project_update_events', to='main.ProjectUpdate')),
],
options={
'ordering': ('pk',),
@@ -72,12 +72,24 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_line', models.PositiveIntegerField(default=0, editable=False)),
('end_line', models.PositiveIntegerField(default=0, editable=False)),
('system_job', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='generic_command_events', to='main.SystemJob')),
('system_job', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='system_job_events', to='main.SystemJob')),
],
options={
'ordering': ('-pk',),
},
),
migrations.AlterIndexTogether(
name='inventoryupdateevent',
index_together=set([('inventory_update', 'start_line'), ('inventory_update', 'uuid'), ('inventory_update', 'end_line')]),
),
migrations.AlterIndexTogether(
name='projectupdateevent',
index_together=set([('project_update', 'event'), ('project_update', 'end_line'), ('project_update', 'start_line'), ('project_update', 'uuid')]),
),
migrations.AlterIndexTogether(
name='systemjobevent',
index_together=set([('system_job', 'end_line'), ('system_job', 'uuid'), ('system_job', 'start_line')]),
),
migrations.RemoveField(
model_name='unifiedjob',
name='result_stdout_file',

View File

@@ -64,12 +64,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='activitystream',
name='o_auth2_access_token',
field=models.ManyToManyField(to='main.OAuth2AccessToken', blank=True, related_name='main_o_auth2_accesstoken'),
field=models.ManyToManyField(to='main.OAuth2AccessToken', blank=True),
),
migrations.AddField(
model_name='activitystream',
name='o_auth2_application',
field=models.ManyToManyField(to='main.OAuth2Application', blank=True, related_name='main_o_auth2_application'),
field=models.ManyToManyField(to='main.OAuth2Application', blank=True),
),
]

View File

@@ -16,6 +16,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='oauth2accesstoken',
name='scope',
field=models.TextField(blank=True, help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write']."),
field=models.TextField(blank=True, default=b'write', help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write']."),
),
]

View File

@@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='oauth2accesstoken',
name='modified',
field=models.DateTimeField(editable=False),
field=models.DateTimeField(editable=False, auto_now=True),
),
]

View File

@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-08-16 16:46
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('main', '0047_v330_activitystream_instance'),
]
operations = [
migrations.AlterField(
model_name='credential',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credential', u'model_name': 'credential'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='credential',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credential', u'model_name': 'credential'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='credentialtype',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credentialtype', u'model_name': 'credentialtype'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='credentialtype',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credentialtype', u'model_name': 'credentialtype'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='custominventoryscript',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'custominventoryscript', u'model_name': 'custominventoryscript'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='custominventoryscript',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'custominventoryscript', u'model_name': 'custominventoryscript'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='group',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'group', u'model_name': 'group'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='group',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'group', u'model_name': 'group'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='host',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'host', u'model_name': 'host'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='host',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'host', u'model_name': 'host'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='inventory',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'inventory', u'model_name': 'inventory'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='inventory',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'inventory', u'model_name': 'inventory'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='label',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'label', u'model_name': 'label'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='label',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'label', u'model_name': 'label'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='notificationtemplate',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'notificationtemplate', u'model_name': 'notificationtemplate'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='notificationtemplate',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'notificationtemplate', u'model_name': 'notificationtemplate'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='organization',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'organization', u'model_name': 'organization'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='organization',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'organization', u'model_name': 'organization'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='schedule',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'schedule', u'model_name': 'schedule'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='schedule',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'schedule', u'model_name': 'schedule'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='team',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'team', u'model_name': 'team'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='team',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'team', u'model_name': 'team'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjob',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjob', u'model_name': 'unifiedjob'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjob',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjob', u'model_name': 'unifiedjob'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjobtemplate',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjobtemplate', u'model_name': 'unifiedjobtemplate'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjobtemplate',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjobtemplate', u'model_name': 'unifiedjobtemplate'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-08-17 16:13
from __future__ import unicode_literals
from decimal import Decimal
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0048_v330_django_created_modified_by_model_name'),
]
operations = [
migrations.AlterField(
model_name='instance',
name='capacity_adjustment',
field=models.DecimalField(decimal_places=2, default=Decimal('1'), max_digits=3, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@@ -35,9 +35,9 @@ def sanitize_event_keys(kwargs, valid_keys):
for key in [
'play', 'role', 'task', 'playbook'
]:
if isinstance(kwargs.get(key), six.string_types):
if len(kwargs[key]) > 1024:
kwargs[key] = Truncator(kwargs[key]).chars(1024)
if isinstance(kwargs.get('event_data', {}).get(key), six.string_types):
if len(kwargs['event_data'][key]) > 1024:
kwargs['event_data'][key] = Truncator(kwargs['event_data'][key]).chars(1024)
def create_host_status_counts(event_data):

View File

@@ -6,6 +6,7 @@ import random
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models, connection
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
@@ -81,6 +82,7 @@ class Instance(HasPolicyEditsMixin, BaseModel):
default=Decimal(1.0),
max_digits=3,
decimal_places=2,
validators=[MinValueValidator(0)]
)
enabled = models.BooleanField(
default=True

View File

@@ -1262,6 +1262,11 @@ class InventorySourceOptions(BaseModel):
'Credentials of type machine, source control, insights and vault are '
'disallowed for custom inventory sources.'
)
elif source == 'scm' and cred and cred.credential_type.kind in ('insights', 'vault'):
return _(
'Credentials of type insights and vault are '
'disallowed for scm inventory sources.'
)
return None
def get_inventory_plugin_name(self):

View File

@@ -238,11 +238,11 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
app_label = 'main'
ordering = ('name',)
host_config_key = models.CharField(
host_config_key = prevent_search(models.CharField(
max_length=1024,
blank=True,
default='',
)
))
ask_diff_mode_on_launch = AskForField(
blank=True,
default=False,

View File

@@ -37,6 +37,7 @@ class SlackBackend(AWXBaseEmailBackend):
if self.color:
ret = connection.api_call("chat.postMessage",
channel=r,
as_user=True,
attachments=[{
"color": self.color,
"text": m.subject

View File

@@ -76,7 +76,8 @@ class TaskManager():
inventory_updates_qs = InventoryUpdate.objects.filter(
status__in=status_list).exclude(source='file').prefetch_related('inventory_source', 'instance_group')
inventory_updates = [i for i in inventory_updates_qs]
project_updates = [p for p in ProjectUpdate.objects.filter(status__in=status_list).prefetch_related('instance_group')]
# Notice the job_type='check': we want to prevent implicit project updates from blocking our jobs.
project_updates = [p for p in ProjectUpdate.objects.filter(status__in=status_list, job_type='check').prefetch_related('instance_group')]
system_jobs = [s for s in SystemJob.objects.filter(status__in=status_list).prefetch_related('instance_group')]
ad_hoc_commands = [a for a in AdHocCommand.objects.filter(status__in=status_list).prefetch_related('instance_group')]
workflow_jobs = [w for w in WorkflowJob.objects.filter(status__in=status_list)]
@@ -678,9 +679,9 @@ class TaskManager():
return finished_wfjs
def schedule(self):
with transaction.atomic():
# Lock
with advisory_lock('task_manager_lock', wait=False) as acquired:
# Lock
with advisory_lock('task_manager_lock', wait=False) as acquired:
with transaction.atomic():
if acquired is False:
logger.debug("Not running scheduler, another task holds lock")
return

View File

@@ -32,7 +32,7 @@ except Exception:
from kombu import Queue, Exchange
from kombu.common import Broadcast
from celery import Task, shared_task
from celery.signals import celeryd_init, worker_shutdown, celeryd_after_setup
from celery.signals import celeryd_init, worker_shutdown
# Django
from django.conf import settings
@@ -108,6 +108,31 @@ def log_celery_failure(self, exc, task_id, args, kwargs, einfo):
@celeryd_init.connect
def celery_startup(conf=None, **kwargs):
#
# When celeryd starts, if the instance cannot be found in the database,
# automatically register it. This is mostly useful for openshift-based
# deployments where:
#
# 2 Instances come online
# Instance B encounters a network blip, Instance A notices, and
# deprovisions it
# Instance B's connectivity is restored, celeryd starts, and it
# re-registers itself
#
# In traditional container-less deployments, instances don't get
# deprovisioned when they miss their heartbeat, so this code is mostly a
# no-op.
#
if kwargs['instance'].hostname != 'celery@{}'.format(settings.CLUSTER_HOST_ID):
error = six.text_type('celery -n {} does not match settings.CLUSTER_HOST_ID={}').format(
instance.hostname, settings.CLUSTER_HOST_ID
)
logger.error(error)
raise RuntimeError(error)
(changed, tower_instance) = Instance.objects.get_or_register()
if changed:
logger.info(six.text_type("Registered tower node '{}'").format(tower_instance.hostname))
startup_logger = logging.getLogger('awx.main.tasks')
startup_logger.info("Syncing Schedules")
for sch in Schedule.objects.all():
@@ -147,9 +172,17 @@ def inform_cluster_of_shutdown(*args, **kwargs):
@shared_task(bind=True, queue=settings.CELERY_DEFAULT_QUEUE)
def apply_cluster_membership_policies(self):
started_waiting = time.time()
with advisory_lock('cluster_policy_lock', wait=True):
lock_time = time.time() - started_waiting
if lock_time > 1.0:
to_log = logger.info
else:
to_log = logger.debug
to_log('Waited {} seconds to obtain lock name: cluster_policy_lock'.format(lock_time))
started_compute = time.time()
all_instances = list(Instance.objects.order_by('id'))
all_groups = list(InstanceGroup.objects.all())
all_groups = list(InstanceGroup.objects.prefetch_related('instances'))
iso_hostnames = set([])
for ig in all_groups:
if ig.controller_id is not None:
@@ -159,28 +192,32 @@ def apply_cluster_membership_policies(self):
total_instances = len(considered_instances)
actual_groups = []
actual_instances = []
Group = namedtuple('Group', ['obj', 'instances'])
Group = namedtuple('Group', ['obj', 'instances', 'prior_instances'])
Node = namedtuple('Instance', ['obj', 'groups'])
# Process policy instance list first, these will represent manually managed memberships
instance_hostnames_map = {inst.hostname: inst for inst in all_instances}
for ig in all_groups:
group_actual = Group(obj=ig, instances=[])
group_actual = Group(obj=ig, instances=[], prior_instances=[
instance.pk for instance in ig.instances.all() # obtained in prefetch
])
for hostname in ig.policy_instance_list:
if hostname not in instance_hostnames_map:
logger.info(six.text_type("Unknown instance {} in {} policy list").format(hostname, ig.name))
continue
inst = instance_hostnames_map[hostname]
logger.info(six.text_type("Policy List, adding Instance {} to Group {}").format(inst.hostname, ig.name))
group_actual.instances.append(inst.id)
# NOTE: arguable behavior: policy-list-group is not added to
# instance's group count for consideration in minimum-policy rules
if group_actual.instances:
logger.info(six.text_type("Policy List, adding Instances {} to Group {}").format(group_actual.instances, ig.name))
if ig.controller_id is None:
actual_groups.append(group_actual)
else:
# For isolated groups, _only_ apply the policy_instance_list
# do not add to in-memory list, so minimum rules not applied
logger.info('Committing instances {} to isolated group {}'.format(group_actual.instances, ig.name))
logger.info('Committing instances to isolated group {}'.format(ig.name))
ig.instances.set(group_actual.instances)
# Process Instance minimum policies next, since it represents a concrete lower bound to the
@@ -189,6 +226,7 @@ def apply_cluster_membership_policies(self):
logger.info("Total non-isolated instances:{} available for policy: {}".format(
total_instances, len(actual_instances)))
for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)):
policy_min_added = []
for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)):
if len(g.instances) >= g.obj.policy_instance_minimum:
break
@@ -196,12 +234,15 @@ def apply_cluster_membership_policies(self):
# If the instance is already _in_ the group, it was
# applied earlier via the policy list
continue
logger.info(six.text_type("Policy minimum, adding Instance {} to Group {}").format(i.obj.hostname, g.obj.name))
g.instances.append(i.obj.id)
i.groups.append(g.obj.id)
policy_min_added.append(i.obj.id)
if policy_min_added:
logger.info(six.text_type("Policy minimum, adding Instances {} to Group {}").format(policy_min_added, g.obj.name))
# Finally, process instance policy percentages
for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)):
policy_per_added = []
for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)):
if i.obj.id in g.instances:
# If the instance is already _in_ the group, it was
@@ -209,15 +250,34 @@ def apply_cluster_membership_policies(self):
continue
if 100 * float(len(g.instances)) / len(actual_instances) >= g.obj.policy_instance_percentage:
break
logger.info(six.text_type("Policy percentage, adding Instance {} to Group {}").format(i.obj.hostname, g.obj.name))
g.instances.append(i.obj.id)
i.groups.append(g.obj.id)
policy_per_added.append(i.obj.id)
if policy_per_added:
logger.info(six.text_type("Policy percentage, adding Instances {} to Group {}").format(policy_per_added, g.obj.name))
# Determine if any changes need to be made
needs_change = False
for g in actual_groups:
if set(g.instances) != set(g.prior_instances):
needs_change = True
break
if not needs_change:
logger.info('Cluster policy no-op finished in {} seconds'.format(time.time() - started_compute))
return
# On a differential basis, apply instances to non-isolated groups
with transaction.atomic():
for g in actual_groups:
logger.info('Committing instances {} to group {}'.format(g.instances, g.obj.name))
g.obj.instances.set(g.instances)
instances_to_add = set(g.instances) - set(g.prior_instances)
instances_to_remove = set(g.prior_instances) - set(g.instances)
if instances_to_add:
logger.info('Adding instances {} to group {}'.format(list(instances_to_add), g.obj.name))
g.obj.instances.add(*instances_to_add)
if instances_to_remove:
logger.info('Removing instances {} from group {}'.format(list(instances_to_remove), g.obj.name))
g.obj.instances.remove(*instances_to_remove)
logger.info('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute))
@shared_task(exchange='tower_broadcast_all', bind=True)
@@ -233,34 +293,6 @@ def handle_setting_changes(self, setting_keys):
cache.delete_many(cache_keys)
@celeryd_after_setup.connect
def auto_register_ha_instance(sender, instance, **kwargs):
#
# When celeryd starts, if the instance cannot be found in the database,
# automatically register it. This is mostly useful for openshift-based
# deployments where:
#
# 2 Instances come online
# Instance B encounters a network blip, Instance A notices, and
# deprovisions it
# Instance B's connectivity is restored, celeryd starts, and it
# re-registers itself
#
# In traditional container-less deployments, instances don't get
# deprovisioned when they miss their heartbeat, so this code is mostly a
# no-op.
#
if instance.hostname != 'celery@{}'.format(settings.CLUSTER_HOST_ID):
error = six.text_type('celery -n {} does not match settings.CLUSTER_HOST_ID={}').format(
instance.hostname, settings.CLUSTER_HOST_ID
)
logger.error(error)
raise RuntimeError(error)
(changed, tower_instance) = Instance.objects.get_or_register()
if changed:
logger.info(six.text_type("Registered tower node '{}'").format(tower_instance.hostname))
@shared_task(queue=settings.CELERY_DEFAULT_QUEUE)
def send_notifications(notification_list, job_id=None):
if not isinstance(notification_list, list):
@@ -761,12 +793,12 @@ class BaseTask(Task):
os.chmod(path, stat.S_IRUSR)
return path
def add_ansible_venv(self, venv_path, env, add_awx_lib=True):
def add_ansible_venv(self, venv_path, env, add_awx_lib=True, **kwargs):
env['VIRTUAL_ENV'] = venv_path
env['PATH'] = os.path.join(venv_path, "bin") + ":" + env['PATH']
venv_libdir = os.path.join(venv_path, "lib")
if not os.path.exists(venv_libdir):
if not kwargs.get('isolated', False) and not os.path.exists(venv_libdir):
raise RuntimeError(
'a valid Python virtualenv does not exist at {}'.format(venv_path)
)
@@ -1179,7 +1211,7 @@ class RunJob(BaseTask):
plugin_dirs.extend(settings.AWX_ANSIBLE_CALLBACK_PLUGINS)
plugin_path = ':'.join(plugin_dirs)
env = super(RunJob, self).build_env(job, **kwargs)
env = self.add_ansible_venv(job.ansible_virtualenv_path, env, add_awx_lib=kwargs.get('isolated', False))
env = self.add_ansible_venv(job.ansible_virtualenv_path, env, add_awx_lib=kwargs.get('isolated', False), **kwargs)
# Set environment variables needed for inventory and job event
# callbacks to work.
env['JOB_ID'] = str(job.pk)
@@ -2129,8 +2161,7 @@ class RunInventoryUpdate(BaseTask):
elif src == 'scm':
args.append(inventory_update.get_actual_source_path())
elif src == 'custom':
runpath = tempfile.mkdtemp(prefix='awx_inventory_', dir=settings.AWX_PROOT_BASE_PATH)
handle, path = tempfile.mkstemp(dir=runpath)
handle, path = tempfile.mkstemp(dir=kwargs['private_data_dir'])
f = os.fdopen(handle, 'w')
if inventory_update.source_script is None:
raise RuntimeError('Inventory Script does not exist')
@@ -2139,7 +2170,6 @@ class RunInventoryUpdate(BaseTask):
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
args.append(path)
args.append("--custom")
self.cleanup_paths.append(runpath)
args.append('-v%d' % inventory_update.verbosity)
if settings.DEBUG:
args.append('--traceback')

View File

@@ -365,6 +365,116 @@ def test_inventory_source_vars_prohibition(post, inventory, admin_user):
assert 'FOOBAR' in r.data['source_vars'][0]
@pytest.mark.django_db
class TestInventorySourceCredential:
def test_need_cloud_credential(self, inventory, admin_user, post):
"""Test that a cloud-based source requires credential"""
r = post(
url=reverse('api:inventory_source_list'),
data={'inventory': inventory.pk, 'name': 'foo', 'source': 'openstack'},
expect=400,
user=admin_user
)
assert 'Credential is required for a cloud source' in r.data['credential'][0]
def test_ec2_no_credential(self, inventory, admin_user, post):
"""Test that an ec2 inventory source can be added with no credential"""
post(
url=reverse('api:inventory_source_list'),
data={'inventory': inventory.pk, 'name': 'fobar', 'source': 'ec2'},
expect=201,
user=admin_user
)
def test_validating_credential_type(self, organization, inventory, admin_user, post):
"""Test that cloud sources must use their respective credential type"""
from awx.main.models.credential import Credential, CredentialType
openstack = CredentialType.defaults['openstack']()
openstack.save()
os_cred = Credential.objects.create(
credential_type=openstack, name='bar', organization=organization)
r = post(
url=reverse('api:inventory_source_list'),
data={
'inventory': inventory.pk, 'name': 'fobar', 'source': 'ec2',
'credential': os_cred.pk
},
expect=400,
user=admin_user
)
assert 'Cloud-based inventory sources (such as ec2)' in r.data['credential'][0]
assert 'require credentials for the matching cloud service' in r.data['credential'][0]
def test_vault_credential_not_allowed(self, project, inventory, vault_credential, admin_user, post):
"""Vault credentials cannot be associated via the deprecated field"""
# TODO: when feature is added, add tests to use the related credentials
# endpoint for multi-vault attachment
r = post(
url=reverse('api:inventory_source_list'),
data={
'inventory': inventory.pk, 'name': 'fobar', 'source': 'scm',
'source_project': project.pk, 'source_path': '',
'credential': vault_credential.pk
},
expect=400,
user=admin_user
)
assert 'Credentials of type insights and vault' in r.data['credential'][0]
assert 'disallowed for scm inventory sources' in r.data['credential'][0]
def test_vault_credential_not_allowed_via_related(
self, project, inventory, vault_credential, admin_user, post):
"""Vault credentials cannot be associated via related endpoint"""
inv_src = InventorySource.objects.create(
inventory=inventory, name='foobar', source='scm',
source_project=project, source_path=''
)
r = post(
url=reverse('api:inventory_source_credentials_list', kwargs={'pk': inv_src.pk}),
data={
'id': vault_credential.pk
},
expect=400,
user=admin_user
)
assert 'Credentials of type insights and vault' in r.data['msg']
assert 'disallowed for scm inventory sources' in r.data['msg']
def test_credentials_relationship_mapping(self, project, inventory, organization, admin_user, post, patch):
"""The credentials relationship is used to manage the cloud credential
this test checks that replacement works"""
from awx.main.models.credential import Credential, CredentialType
openstack = CredentialType.defaults['openstack']()
openstack.save()
os_cred = Credential.objects.create(
credential_type=openstack, name='bar', organization=organization)
r = post(
url=reverse('api:inventory_source_list'),
data={
'inventory': inventory.pk, 'name': 'fobar', 'source': 'scm',
'source_project': project.pk, 'source_path': '',
'credential': os_cred.pk
},
expect=201,
user=admin_user
)
aws = CredentialType.defaults['aws']()
aws.save()
aws_cred = Credential.objects.create(
credential_type=aws, name='bar2', organization=organization)
inv_src = InventorySource.objects.get(pk=r.data['id'])
assert list(inv_src.credentials.values_list('id', flat=True)) == [os_cred.pk]
patch(
url=inv_src.get_absolute_url(),
data={
'credential': aws_cred.pk
},
expect=200,
user=admin_user
)
assert list(inv_src.credentials.values_list('id', flat=True)) == [aws_cred.pk]
@pytest.mark.django_db
class TestControlledBySCM:
'''

View File

@@ -5,7 +5,10 @@ import json
from django.db import connection
from django.test.utils import override_settings
from django.test import Client
from django.core.urlresolvers import resolve
from rest_framework.test import APIRequestFactory
from awx.main.middleware import DeprecatedAuthTokenMiddleware
from awx.main.utils.encryption import decrypt_value, get_encryption_key
from awx.api.versioning import reverse, drf_reverse
from awx.main.models.oauth import (OAuth2Application as Application,
@@ -260,36 +263,6 @@ def test_oauth_list_user_tokens(oauth_application, post, get, admin, alice):
post(url, {'scope': 'read'}, user, expect=201)
response = get(url, admin, expect=200)
assert response.data['count'] == 1
@pytest.mark.django_db
def test_refresh_accesstoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
response = post(
refresh_url,
data='grant_type=refresh_token&refresh_token=' + refresh_token.token,
content_type='application/x-www-form-urlencoded',
HTTP_AUTHORIZATION='Basic ' + base64.b64encode(':'.join([
oauth_application.client_id, oauth_application.client_secret
]))
)
new_token = json.loads(response._container[0])['access_token']
new_refresh_token = json.loads(response._container[0])['refresh_token']
assert token not in AccessToken.objects.all()
assert AccessToken.objects.get(token=new_token) != 0
assert RefreshToken.objects.get(token=new_refresh_token) != 0
refresh_token = RefreshToken.objects.get(token=refresh_token)
assert refresh_token.revoked
@pytest.mark.django_db
@@ -314,3 +287,117 @@ def test_implicit_authorization(oauth_application, admin):
assert 'http://test.com' in response.url and 'access_token' in response.url
# Make sure no refresh token is created for app with implicit grant type.
assert refresh_token_count == RefreshToken.objects.count()
@pytest.mark.django_db
def test_refresh_accesstoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
response = post(
refresh_url,
data='grant_type=refresh_token&refresh_token=' + refresh_token.token,
content_type='application/x-www-form-urlencoded',
HTTP_AUTHORIZATION='Basic ' + base64.b64encode(':'.join([
oauth_application.client_id, oauth_application.client_secret
]))
)
assert RefreshToken.objects.filter(token=refresh_token).exists()
original_refresh_token = RefreshToken.objects.get(token=refresh_token)
assert token not in AccessToken.objects.all()
assert AccessToken.objects.count() == 1
# the same RefreshToken remains but is marked revoked
assert RefreshToken.objects.count() == 2
new_token = json.loads(response._container[0])['access_token']
new_refresh_token = json.loads(response._container[0])['refresh_token']
assert AccessToken.objects.filter(token=new_token).count() == 1
# checks that RefreshTokens are rotated (new RefreshToken issued)
assert RefreshToken.objects.filter(token=new_refresh_token).count() == 1
assert original_refresh_token.revoked # is not None
@pytest.mark.django_db
def test_revoke_access_then_refreshtoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
token.revoke()
assert AccessToken.objects.count() == 0
assert RefreshToken.objects.count() == 1
assert not refresh_token.revoked
refresh_token.revoke()
assert AccessToken.objects.count() == 0
assert RefreshToken.objects.count() == 1
@pytest.mark.django_db
def test_revoke_refreshtoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
refresh_token.revoke()
assert AccessToken.objects.count() == 0
# the same RefreshToken is recycled
new_refresh_token = RefreshToken.objects.all().first()
assert refresh_token == new_refresh_token
assert new_refresh_token.revoked
@pytest.mark.django_db
@pytest.mark.parametrize('fmt', ['json', 'multipart'])
def test_deprecated_authtoken_support(alice, fmt):
kwargs = {
'data': {'username': 'alice', 'password': 'alice'},
'format': fmt
}
request = getattr(APIRequestFactory(), 'post')('/api/v2/authtoken/', **kwargs)
DeprecatedAuthTokenMiddleware().process_request(request)
assert request.path == request.path_info == '/api/v2/users/{}/personal_tokens/'.format(alice.pk)
view, view_args, view_kwargs = resolve(request.path)
resp = view(request, *view_args, **view_kwargs)
assert resp.status_code == 201
assert 'token' in resp.data
assert resp.data['refresh_token'] is None
assert resp.data['scope'] == 'write'
@pytest.mark.django_db
def test_deprecated_authtoken_invalid_username(alice):
kwargs = {
'data': {'username': 'nobody', 'password': 'nobody'},
'format': 'json'
}
request = getattr(APIRequestFactory(), 'post')('/api/v2/authtoken/', **kwargs)
resp = DeprecatedAuthTokenMiddleware().process_request(request)
assert resp.status_code == 401
@pytest.mark.django_db
def test_deprecated_authtoken_missing_credentials(alice):
kwargs = {
'data': {},
'format': 'json'
}
request = getattr(APIRequestFactory(), 'post')('/api/v2/authtoken/', **kwargs)
resp = DeprecatedAuthTokenMiddleware().process_request(request)
assert resp.status_code == 401

View File

@@ -34,8 +34,17 @@ class TestOAuth2Application:
client_type='confidential', authorization_grant_type='password', organization=organization
)
assert access.can_read(app) is can_access
def test_admin_only_can_read(self, user, organization):
user = user('org-admin', False)
organization.admin_role.members.add(user)
access = OAuth2ApplicationAccess(user)
app = Application.objects.create(
name='test app for {}'.format(user.username), user=user,
client_type='confidential', authorization_grant_type='password', organization=organization
)
assert access.can_read(app) is True
def test_app_activity_stream(self, org_admin, alice, organization):
app = Application.objects.create(
name='test app for {}'.format(org_admin.username), user=org_admin,

View File

@@ -53,9 +53,9 @@ def test_really_long_event_fields(field):
with mock.patch.object(JobEvent, 'objects') as manager:
JobEvent.create_from_data(**{
'job_id': 123,
field: 'X' * 4096
'event_data': {field: 'X' * 4096}
})
manager.create.assert_called_with(**{
'job_id': 123,
field: 'X' * 1021 + '...'
'event_data': {field: 'X' * 1021 + '...'}
})

View File

@@ -2,7 +2,6 @@
# Python
import pytest
import mock
from collections import namedtuple
# AWX
from awx.main.utils.filters import SmartFilter, ExternalLoggerEnabled
@@ -44,8 +43,26 @@ def test_log_configurable_severity(level, expect, dummy_log_record):
assert filter.filter(dummy_log_record) is expect
Field = namedtuple('Field', 'name')
Meta = namedtuple('Meta', 'fields')
class Field(object):
def __init__(self, name, related_model=None, __prevent_search__=None):
self.name = name
self.related_model = related_model
self.__prevent_search__ = __prevent_search__
class Meta(object):
def __init__(self, fields):
self._fields = {
f.name: f for f in fields
}
self.object_name = 'Host'
self.fields_map = {}
self.fields = self._fields.values()
def get_field(self, f):
return self._fields.get(f)
class mockObjects:
@@ -53,15 +70,32 @@ class mockObjects:
return Q(*args, **kwargs)
class mockUser:
def __init__(self):
print("Host user created")
self._meta = Meta(fields=[
Field(name='password', __prevent_search__=True)
])
class mockHost:
def __init__(self):
print("Host mock created")
self.objects = mockObjects()
self._meta = Meta(fields=(Field(name='name'), Field(name='description')))
fields = [
Field(name='name'),
Field(name='description'),
Field(name='created_by', related_model=mockUser())
]
self._meta = Meta(fields=fields)
@mock.patch('awx.main.utils.filters.get_model', return_value=mockHost())
class TestSmartFilterQueryFromString():
@mock.patch(
'awx.api.filters.get_field_from_path',
lambda model, path: (model, path) # disable field filtering, because a__b isn't a real Host field
)
@pytest.mark.parametrize("filter_string,q_expected", [
('facts__facts__blank=""', Q(**{u"facts__facts__blank": u""})),
('"facts__facts__ space "="f"', Q(**{u"facts__facts__ space ": u"f"})),
@@ -88,6 +122,16 @@ class TestSmartFilterQueryFromString():
SmartFilter.query_from_string(filter_string)
assert e.value.message == u"Invalid query " + filter_string
@pytest.mark.parametrize("filter_string", [
'created_by__password__icontains=pbkdf2'
'search=foo or created_by__password__icontains=pbkdf2',
'created_by__password__icontains=pbkdf2 or search=foo',
])
def test_forbidden_filter_string(self, mock_get_host_model, filter_string):
with pytest.raises(Exception) as e:
SmartFilter.query_from_string(filter_string)
"Filtering on password is not allowed." in str(e)
@pytest.mark.parametrize("filter_string,q_expected", [
(u'(a=abc\u1F5E3def)', Q(**{u"a": u"abc\u1F5E3def"})),
(u'(ansible_facts__a=abc\u1F5E3def)', Q(**{u"ansible_facts__contains": {u"a": u"abc\u1F5E3def"}})),

View File

@@ -1,68 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Ansible Tower by Red Hat
# All Rights Reserved.
# python
import pytest
import mock
# AWX
from awx.main.utils.ha import (
AWXCeleryRouter,
)
class TestAddRemoveCeleryWorkerQueues():
@pytest.fixture
def instance_generator(self, mocker):
def fn(hostname='east-1'):
groups=['east', 'west', 'north', 'south']
instance = mocker.MagicMock()
instance.hostname = hostname
instance.rampart_groups = mocker.MagicMock()
instance.rampart_groups.values_list = mocker.MagicMock(return_value=groups)
return instance
return fn
@pytest.fixture
def worker_queues_generator(self, mocker):
def fn(queues=['east', 'west']):
return [dict(name=n, alias='') for n in queues]
return fn
@pytest.fixture
def mock_app(self, mocker):
app = mocker.MagicMock()
app.control = mocker.MagicMock()
app.control.cancel_consumer = mocker.MagicMock()
return app
class TestUpdateCeleryWorkerRouter():
@pytest.mark.parametrize("is_controller,expected_routes", [
(False, {
'awx.main.tasks.cluster_node_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'},
'awx.main.tasks.purge_old_stdout_files': {'queue': 'east-1', 'routing_key': 'east-1'}
}),
(True, {
'awx.main.tasks.cluster_node_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'},
'awx.main.tasks.purge_old_stdout_files': {'queue': 'east-1', 'routing_key': 'east-1'},
'awx.main.tasks.awx_isolated_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'},
}),
])
def test_update_celery_worker_routes(self, mocker, is_controller, expected_routes):
def get_or_register():
instance = mock.MagicMock()
instance.hostname = 'east-1'
instance.is_controller = mock.MagicMock(return_value=is_controller)
return (False, instance)
with mock.patch('awx.main.models.Instance.objects.get_or_register', get_or_register):
router = AWXCeleryRouter()
for k,v in expected_routes.iteritems():
assert router.route_for_task(k) == v

View File

@@ -147,6 +147,10 @@ class SmartFilter(object):
q = reduce(lambda x, y: x | y, [models.Q(**{u'%s__icontains' % _k:_v}) for _k, _v in kwargs.items()])
self.result = Host.objects.filter(q)
else:
# detect loops and restrict access to sensitive fields
# this import is intentional here to avoid a circular import
from awx.api.filters import FieldLookupBackend
FieldLookupBackend().get_field_from_lookup(Host, k)
kwargs[k] = v
self.result = Host.objects.filter(**kwargs)

View File

@@ -3,21 +3,15 @@
# Copyright (c) 2017 Ansible Tower by Red Hat
# All Rights Reserved.
from awx.main.models import Instance
from django.conf import settings
class AWXCeleryRouter(object):
def route_for_task(self, task, args=None, kwargs=None):
(changed, instance) = Instance.objects.get_or_register()
tasks = [
'awx.main.tasks.cluster_node_heartbeat',
'awx.main.tasks.purge_old_stdout_files',
]
isolated_tasks = [
'awx.main.tasks.awx_isolated_heartbeat',
]
if task in tasks:
return {'queue': instance.hostname.encode("utf8"), 'routing_key': instance.hostname.encode("utf8")}
if instance.is_controller() and task in isolated_tasks:
return {'queue': instance.hostname.encode("utf8"), 'routing_key': instance.hostname.encode("utf8")}
return {'queue': settings.CLUSTER_HOST_ID, 'routing_key': settings.CLUSTER_HOST_ID}

View File

@@ -261,6 +261,7 @@ MIDDLEWARE_CLASSES = ( # NOQA
'awx.sso.middleware.SocialAuthMiddleware',
'crum.CurrentRequestUserMiddleware',
'awx.main.middleware.URLModificationMiddleware',
'awx.main.middleware.DeprecatedAuthTokenMiddleware',
'awx.main.middleware.SessionTimeoutMiddleware',
)
@@ -1177,16 +1178,13 @@ LOGGING = {
'propagate': False
},
'awx.main.access': {
'handlers': ['null'],
'propagate': False,
'level': 'INFO', # very verbose debug-level logs
},
'awx.main.signals': {
'handlers': ['null'],
'propagate': False,
'level': 'INFO', # very verbose debug-level logs
},
'awx.api.permissions': {
'handlers': ['null'],
'propagate': False,
'level': 'INFO', # very verbose debug-level logs
},
'awx.analytics': {
'handlers': ['external_logger'],

View File

@@ -1,3 +1,16 @@
@import 'portalMode/_index';
@import 'output/_index';
@import 'users/tokens/_index';
/** @define Popup Modal after create new token and applicaiton and save form */
.PopupModal {
display: flex;
}
.PopupModal-label {
font-weight: bold;
width: 130px;
}
.PopupModal-value {
width: 70%;
}

View File

@@ -1,4 +1,4 @@
function AddApplicationsController (models, $state, strings, $scope) {
function AddApplicationsController (models, $state, strings, $scope, Alert, $filter) {
const vm = this || {};
const { application, me, organization } = models;
@@ -60,6 +60,41 @@ function AddApplicationsController (models, $state, strings, $scope) {
};
vm.form.onSaveSuccess = res => {
if (res.data && res.data.client_id) {
const name = res.data.name ?
`<div class="PopupModal">
<div class="PopupModal-label">
${strings.get('add.NAME_LABEL')}
</div>
<div class="PopupModal-value">
${$filter('sanitize')(res.data.name)}
</div>
</div>` : '';
const clientId = res.data.client_id ?
`<div class="PopupModal">
<div class="PopupModal-label">
${strings.get('add.CLIENT_ID_LABEL')}
</div>
<div class="PopupModal-value">
${res.data.client_id}
</div>
</div>` : '';
const clientSecret = res.data.client_secret ?
`<div class="PopupModal">
<div class="PopupModal-label">
${strings.get('add.CLIENT_SECRECT_LABEL')}
</div>
<div class="PopupModal-value">
${res.data.client_secret}
</div>
</div>` : '';
Alert(strings.get('add.MODAL_HEADER'), `
${name}
${clientId}
${clientSecret}
`, null, null, null, null, null, true);
}
$state.go('applications.edit', { application_id: res.data.id }, { reload: true });
};
@@ -74,7 +109,9 @@ AddApplicationsController.$inject = [
'resolvedModels',
'$state',
'ApplicationsStrings',
'$scope'
'$scope',
'Alert',
'$filter',
];
export default AddApplicationsController;

View File

@@ -21,7 +21,11 @@ function ApplicationsStrings (BaseString) {
};
ns.add = {
PANEL_TITLE: t.s('NEW APPLICATION')
PANEL_TITLE: t.s('NEW APPLICATION'),
CLIENT_ID_LABEL: t.s('CLIENT ID'),
CLIENT_SECRECT_LABEL: t.s('CLIENT SECRET'),
MODAL_HEADER: t.s('APPLICATION INFORMATION'),
NAME_LABEL: t.s('NAME'),
};
ns.list = {

View File

@@ -38,6 +38,16 @@
}
}
&-menuIcon--md {
font-size: 14px;
padding: 10px;
cursor: pointer;
&:hover {
color: @at-blue;
}
}
&-menuIcon--lg {
font-size: 22px;
line-height: 12px;
@@ -94,6 +104,10 @@
user-select: none;
}
&-line--clickable {
cursor: pointer;
}
&-event {
.at-mixin-event();
}
@@ -138,6 +152,10 @@
margin: 0;
overflow-y: scroll;
padding: 0;
@media screen and (max-width: @breakpoint-md) {
max-height: calc(100vh - 30px);
}
}
&-borderHeader {

View File

@@ -13,9 +13,30 @@ export const JOB_STATUS_INCOMPLETE = ['canceled', 'error'];
export const JOB_STATUS_UNSUCCESSFUL = ['failed'].concat(JOB_STATUS_INCOMPLETE);
export const JOB_STATUS_FINISHED = JOB_STATUS_COMPLETE.concat(JOB_STATUS_INCOMPLETE);
export const OUTPUT_ANSI_COLORMAP = {
0: '#000',
1: '#A00',
2: '#0A0',
3: '#F0AD4E',
4: '#00A',
5: '#A0A',
6: '#0AA',
7: '#AAA',
8: '#555',
9: '#F55',
10: '#5F5',
11: '#FF5',
12: '#55F',
13: '#F5F',
14: '#5FF',
15: '#FFF'
};
export const OUTPUT_ELEMENT_CONTAINER = '.at-Stdout-container';
export const OUTPUT_ELEMENT_TBODY = '#atStdoutResultTable';
export const OUTPUT_ELEMENT_LAST = '#atStdoutMenuLast';
export const OUTPUT_MAX_BUFFER_LENGTH = 1000;
export const OUTPUT_MAX_LAG = 120;
export const OUTPUT_NO_COUNT_JOB_TYPES = ['ad_hoc_command', 'system_job', 'inventory_update'];
export const OUTPUT_ORDER_BY = 'counter';
export const OUTPUT_PAGE_CACHE = true;
export const OUTPUT_PAGE_LIMIT = 5;
@@ -23,8 +44,8 @@ export const OUTPUT_PAGE_SIZE = 50;
export const OUTPUT_SCROLL_DELAY = 100;
export const OUTPUT_SCROLL_THRESHOLD = 0.1;
export const OUTPUT_SEARCH_DOCLINK = 'https://docs.ansible.com/ansible-tower/3.3.0/html/userguide/search_sort.html';
export const OUTPUT_SEARCH_FIELDS = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play'];
export const OUTPUT_SEARCH_KEY_EXAMPLES = ['host_name:localhost', 'task:set', 'created:>=2000-01-01'];
export const OUTPUT_SEARCH_FIELDS = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play', 'start_line', 'end_line'];
export const OUTPUT_SEARCH_KEY_EXAMPLES = ['host_name:localhost', 'task:set', 'created:>=2000-01-01', 'start_line:>=9000'];
export const OUTPUT_EVENT_LIMIT = OUTPUT_PAGE_LIMIT * OUTPUT_PAGE_SIZE;
export const WS_PREFIX = 'ws';

View File

@@ -2,6 +2,7 @@
import {
EVENT_START_PLAY,
EVENT_START_TASK,
OUTPUT_ELEMENT_LAST,
OUTPUT_PAGE_SIZE,
} from './constants';
@@ -16,61 +17,21 @@ let scroll;
let status;
let slide;
let stream;
let page;
let vm;
const bufferState = [0, 0]; // [length, count]
const listeners = [];
const rx = [];
let lockFrames = false;
function bufferInit () {
rx.length = 0;
bufferState[0] = 0;
bufferState[1] = 0;
}
function bufferAdd (event) {
rx.push(event);
bufferState[0] += 1;
bufferState[1] += 1;
return bufferState[1];
}
function bufferEmpty (min, max) {
let count = 0;
let removed = [];
for (let i = bufferState[0] - 1; i >= 0; i--) {
if (rx[i].counter <= max) {
removed = removed.concat(rx.splice(i, 1));
count++;
}
}
bufferState[0] -= count;
return removed;
}
let lockFrames;
function onFrames (events) {
if (lockFrames) {
events.forEach(bufferAdd);
return $q.resolve();
}
events = slide.pushFrames(events);
const popCount = events.length - slide.getCapacity();
const isAttached = events.length > 0;
if (!isAttached) {
stopFollowing();
if (lockFrames) {
return $q.resolve();
}
const popCount = events.length - render.getCapacity();
if (!vm.isFollowing && canStartFollowing()) {
startFollowing();
}
@@ -85,13 +46,13 @@ function onFrames (events) {
scroll.scrollToBottom();
}
return slide.popBack(popCount)
return render.popBack(popCount)
.then(() => {
if (vm.isFollowing) {
scroll.scrollToBottom();
}
return slide.pushFront(events);
return render.pushFront(events);
})
.then(() => {
if (vm.isFollowing) {
@@ -104,27 +65,44 @@ function onFrames (events) {
});
}
function first () {
//
// Menu Controls (Running)
//
function firstRange () {
if (scroll.isPaused()) {
return $q.resolve();
}
stopFollowing();
lockFollow = true;
if (slide.isOnFirstPage()) {
scroll.resetScrollPosition();
return $q.resolve();
}
scroll.pause();
lockFrames = true;
stopFollowing();
return render.clear()
.then(() => slide.getFirst())
.then(results => render.pushFront(results))
.then(() => slide.getNext())
.then(results => {
const popCount = results.length - render.getCapacity();
return slide.getFirst()
.then(() => {
scroll.resetScrollPosition();
return render.popBack(popCount)
.then(() => render.pushFront(results));
})
.finally(() => {
scroll.resume();
lockFrames = false;
lockFollow = false;
});
}
function next () {
function nextRange () {
if (vm.isFollowing) {
scroll.scrollToBottom();
@@ -135,34 +113,49 @@ function next () {
return $q.resolve();
}
if (slide.getTailCounter() >= slide.getMaxCounter()) {
return $q.resolve();
}
scroll.pause();
lockFrames = true;
return slide.getNext()
.then(results => {
const popCount = results.length - render.getCapacity();
return render.popBack(popCount)
.then(() => render.pushFront(results));
})
.finally(() => {
scroll.resume();
lockFrames = false;
return $q.resolve();
});
}
function previous () {
function previousRange () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
stopFollowing();
lockFrames = true;
stopFollowing();
const initialPosition = scroll.getScrollPosition();
let initialPosition;
let popHeight;
return slide.getPrevious()
.then(popHeight => {
.then(results => {
const popCount = results.length - render.getCapacity();
initialPosition = scroll.getScrollPosition();
return render.popFront(popCount)
.then(() => {
popHeight = scroll.getScrollHeight();
return render.pushBack(results);
});
})
.then(() => {
const currentHeight = scroll.getScrollHeight();
scroll.setScrollPosition(currentHeight - popHeight + initialPosition);
@@ -171,10 +164,12 @@ function previous () {
.finally(() => {
scroll.resume();
lockFrames = false;
return $q.resolve();
});
}
function last () {
function lastRange () {
if (scroll.isPaused()) {
return $q.resolve();
}
@@ -182,16 +177,39 @@ function last () {
scroll.pause();
lockFrames = true;
return slide.getLast()
return render.clear()
.then(() => slide.getLast())
.then(results => render.pushFront(results))
.then(() => {
stream.setMissingCounterThreshold(slide.getTailCounter() + 1);
scroll.scrollToBottom();
lockFrames = false;
return $q.resolve();
})
.finally(() => {
scroll.resume();
lockFrames = false;
return $q.resolve();
});
}
function menuLastRange () {
if (vm.isFollowing) {
lockFollow = true;
stopFollowing();
return $q.resolve();
}
lockFollow = false;
return lastRange()
.then(() => {
startFollowing();
return $q.resolve();
});
}
@@ -210,8 +228,7 @@ function canStartFollowing () {
if (followOnce && // one-time activation from top of first page
scroll.isBeyondUpperThreshold() &&
slide.getHeadCounter() === 1 &&
slide.getTailCounter() >= OUTPUT_PAGE_SIZE) {
slide.getTailCounter() - slide.getHeadCounter() >= OUTPUT_PAGE_SIZE) {
followOnce = false;
return true;
@@ -234,27 +251,166 @@ function stopFollowing () {
return;
}
scroll.unlock();
scroll.unhide();
vm.isFollowing = false;
vm.followTooltip = vm.strings.get('tooltips.MENU_LAST');
}
//
// Menu Controls (Page Mode)
//
function firstPage () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
return render.clear()
.then(() => page.getFirst())
.then(results => render.pushFront(results))
.then(() => page.getNext())
.then(results => {
const popCount = page.trimHead();
return render.popBack(popCount)
.then(() => render.pushFront(results));
})
.finally(() => {
scroll.resume();
return $q.resolve();
});
}
function lastPage () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
return render.clear()
.then(() => page.getLast())
.then(results => render.pushBack(results))
.then(() => page.getPrevious())
.then(results => {
const popCount = page.trimTail();
return render.popFront(popCount)
.then(() => render.pushBack(results));
})
.then(() => {
scroll.scrollToBottom();
return $q.resolve();
})
.finally(() => {
scroll.resume();
return $q.resolve();
});
}
function nextPage () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
return page.getNext()
.then(results => {
const popCount = page.trimHead();
return render.popBack(popCount)
.then(() => render.pushFront(results));
})
.finally(() => {
scroll.resume();
});
}
function previousPage () {
if (scroll.isPaused()) {
return $q.resolve();
}
scroll.pause();
let initialPosition;
let popHeight;
return page.getPrevious()
.then(results => {
const popCount = page.trimTail();
initialPosition = scroll.getScrollPosition();
return render.popFront(popCount)
.then(() => {
popHeight = scroll.getScrollHeight();
return render.pushBack(results);
});
})
.then(() => {
const currentHeight = scroll.getScrollHeight();
scroll.setScrollPosition(currentHeight - popHeight + initialPosition);
return $q.resolve();
})
.finally(() => {
scroll.resume();
return $q.resolve();
});
}
//
// Menu Controls
//
function first () {
if (vm.isProcessingFinished) {
return firstPage();
}
return firstRange();
}
function last () {
if (vm.isProcessingFinished) {
return lastPage();
}
return lastRange();
}
function next () {
if (vm.isProcessingFinished) {
return nextPage();
}
return nextRange();
}
function previous () {
if (vm.isProcessingFinished) {
return previousPage();
}
return previousRange();
}
function menuLast () {
if (vm.isFollowing) {
lockFollow = true;
stopFollowing();
return $q.resolve();
if (vm.isProcessingFinished) {
return lastPage();
}
lockFollow = false;
if (slide.isOnLastPage()) {
scroll.scrollToBottom();
return $q.resolve();
}
return last();
return menuLastRange();
}
function down () {
@@ -269,64 +425,125 @@ function togglePanelExpand () {
vm.isPanelExpanded = !vm.isPanelExpanded;
}
function toggleMenuExpand () {
//
// Line Interaction
//
const iconCollapsed = 'fa-angle-right';
const iconExpanded = 'fa-angle-down';
const iconSelector = '.at-Stdout-toggle > i';
const lineCollapsed = 'hidden';
function toggleCollapseAll () {
if (scroll.isPaused()) return;
const recordList = Object.keys(render.record).map(key => render.record[key]);
const playRecords = recordList.filter(({ name }) => name === EVENT_START_PLAY);
const playIds = playRecords.map(({ uuid }) => uuid);
const records = Object.keys(render.records).map(key => render.records[key]);
const plays = records.filter(({ name }) => name === EVENT_START_PLAY);
const tasks = records.filter(({ name }) => name === EVENT_START_TASK);
// get any task record that does not have a parent play record
const orphanTaskRecords = recordList
.filter(({ name }) => name === EVENT_START_TASK)
.filter(({ parents }) => !parents.some(uuid => playIds.indexOf(uuid) >= 0));
const orphanLines = records
.filter(({ level }) => level === 3)
.filter(({ parents }) => !records[parents[0]]);
const toggled = playRecords.concat(orphanTaskRecords)
.map(({ uuid }) => getToggleElements(uuid))
.filter(({ icon }) => icon.length > 0)
.map(({ icon, lines }) => setExpanded(icon, lines, !vm.isMenuExpanded));
const orphanLineParents = orphanLines
.map(({ parents }) => ({ uuid: parents[0] }));
if (toggled.length > 0) {
vm.isMenuExpanded = !vm.isMenuExpanded;
plays.concat(tasks).forEach(({ uuid }) => {
const icon = $(`#${uuid} ${iconSelector}`);
if (vm.isMenuCollapsed) {
icon.removeClass(iconCollapsed);
icon.addClass(iconExpanded);
} else {
icon.removeClass(iconExpanded);
icon.addClass(iconCollapsed);
}
});
tasks.concat(orphanLineParents).forEach(({ uuid }) => {
const lines = $(`.child-of-${uuid}`);
if (vm.isMenuCollapsed) {
lines.removeClass(lineCollapsed);
} else {
lines.addClass(lineCollapsed);
}
});
vm.isMenuCollapsed = !vm.isMenuCollapsed;
render.setCollapseAll(vm.isMenuCollapsed);
}
function toggleCollapse (uuid) {
if (scroll.isPaused()) return;
const record = render.records[uuid];
if (record.name === EVENT_START_PLAY) {
togglePlayCollapse(uuid);
}
if (record.name === EVENT_START_TASK) {
toggleTaskCollapse(uuid);
}
}
function toggleLineExpand (uuid) {
if (scroll.isPaused()) return;
function togglePlayCollapse (uuid) {
const record = render.records[uuid];
const descendants = record.children || [];
const { icon, lines } = getToggleElements(uuid);
const isExpanded = icon.hasClass('fa-angle-down');
const icon = $(`#${uuid} ${iconSelector}`);
const lines = $(`.child-of-${uuid}`);
const taskIcons = $(`#${descendants.join(', #')}`).find(iconSelector);
setExpanded(icon, lines, !isExpanded);
const isCollapsed = icon.hasClass(iconCollapsed);
vm.isMenuExpanded = !isExpanded;
if (isCollapsed) {
icon.removeClass(iconCollapsed);
icon.addClass(iconExpanded);
taskIcons.removeClass(iconExpanded);
taskIcons.addClass(iconCollapsed);
lines.removeClass(lineCollapsed);
descendants
.map(item => $(`.child-of-${item}`))
.forEach(line => line.addClass(lineCollapsed));
} else {
icon.removeClass(iconExpanded);
icon.addClass(iconCollapsed);
taskIcons.removeClass(iconExpanded);
taskIcons.addClass(iconCollapsed);
lines.addClass(lineCollapsed);
}
descendants
.map(item => render.records[item])
.filter(({ name }) => name === EVENT_START_TASK)
.forEach(rec => { render.records[rec.uuid].isCollapsed = true; });
render.records[uuid].isCollapsed = !isCollapsed;
}
function getToggleElements (uuid) {
const record = render.record[uuid];
function toggleTaskCollapse (uuid) {
const icon = $(`#${uuid} ${iconSelector}`);
const lines = $(`.child-of-${uuid}`);
const iconSelector = '.at-Stdout-toggle > i';
const additionalSelector = `#${(record.children || []).join(', #')}`;
const isCollapsed = icon.hasClass(iconCollapsed);
let icon = $(`#${uuid} ${iconSelector}`);
if (additionalSelector) {
icon = icon.add($(additionalSelector).find(iconSelector));
}
return { icon, lines };
}
function setExpanded (icon, lines, expanded) {
if (expanded) {
icon.removeClass('fa-angle-right');
icon.addClass('fa-angle-down');
lines.removeClass('hidden');
if (isCollapsed) {
icon.removeClass(iconCollapsed);
icon.addClass(iconExpanded);
lines.removeClass(lineCollapsed);
} else {
icon.removeClass('fa-angle-down');
icon.addClass('fa-angle-right');
lines.addClass('hidden');
icon.removeClass(iconExpanded);
icon.addClass(iconCollapsed);
lines.addClass(lineCollapsed);
}
render.records[uuid].isCollapsed = !isCollapsed;
}
function compile (html) {
@@ -337,6 +554,60 @@ function showHostDetails (id, uuid) {
$state.go('output.host-event.json', { eventId: id, taskUuid: uuid });
}
function showMissingEvents (uuid) {
const record = render.records[uuid];
const min = Math.min(...record.counters);
const max = Math.min(Math.max(...record.counters), min + OUTPUT_PAGE_SIZE);
const selector = `#${uuid}`;
const clicked = $(selector);
return resource.events.getRange([min, max])
.then(results => {
const counters = results.map(({ counter }) => counter);
for (let i = min; i <= max; i++) {
if (counters.indexOf(i) < 0) {
results = results.filter(({ counter }) => counter < i);
break;
}
}
let lines = 0;
let untrusted = '';
for (let i = 0; i <= results.length - 1; i++) {
const { html, count } = render.transformEvent(results[i]);
lines += count;
untrusted += html;
const shifted = render.records[uuid].counters.shift();
delete render.uuids[shifted];
}
const trusted = render.trustHtml(untrusted);
const elements = angular.element(trusted);
return render
.requestAnimationFrame(() => {
elements.insertBefore(clicked);
if (render.records[uuid].counters.length === 0) {
clicked.remove();
delete render.records[uuid];
}
})
.then(() => render.compile(elements))
.then(() => lines);
});
}
//
// Event Handling
//
let streaming;
function stopListening () {
streaming = null;
@@ -361,7 +632,7 @@ function handleJobEvent (data) {
streaming = streaming || resource.events
.getRange([Math.max(1, data.counter - 50), data.counter + 50])
.then(results => {
results = results.concat(data);
results.push(data);
const counters = results.map(({ counter }) => counter);
const min = Math.min(...counters);
@@ -379,12 +650,13 @@ function handleJobEvent (data) {
results = results.filter(({ counter }) => counter > maxMissing);
}
stream.setMissingCounterThreshold(max + 1);
results.forEach(item => {
stream.pushJobEvent(item);
status.pushJobEvent(item);
});
stream.setMissingCounterThreshold(min);
return $q.resolve();
});
@@ -406,12 +678,36 @@ function handleSummaryEvent (data) {
stream.setFinalCounter(data.final_counter);
}
//
// Search
//
function reloadState (params) {
params.isPanelExpanded = vm.isPanelExpanded;
return $state.transitionTo($state.current, params, { inherit: false, location: 'replace' });
}
//
// Debug Mode
//
function clear () {
stopListening();
render.clear();
followOnce = true;
lockFollow = false;
lockFrames = false;
stream.bufferInit();
status.init(resource);
slide.init(resource.events, render);
status.subscribe(data => { vm.status = data.status; });
startListening();
}
function OutputIndexController (
_$compile_,
_$q_,
@@ -428,7 +724,8 @@ function OutputIndexController (
strings,
$stateParams,
) {
const { isPanelExpanded } = $stateParams;
const { isPanelExpanded, _debug } = $stateParams;
const isProcessingFinished = !_debug && _resource_.model.get('event_processing_finished');
$compile = _$compile_;
$q = _$q_;
@@ -440,7 +737,8 @@ function OutputIndexController (
render = _render_;
status = _status_;
stream = _stream_;
slide = resource.model.get('event_processing_finished') ? _page_ : _slide_;
slide = _slide_;
page = _page_;
vm = this || {};
@@ -451,24 +749,27 @@ function OutputIndexController (
vm.resource = resource;
vm.reloadState = reloadState;
vm.isPanelExpanded = isPanelExpanded;
vm.isProcessingFinished = isProcessingFinished;
vm.togglePanelExpand = togglePanelExpand;
// Stdout Navigation
vm.menu = { last: menuLast, first, down, up };
vm.isMenuExpanded = true;
vm.menu = { last: menuLast, first, down, up, clear };
vm.isMenuCollapsed = false;
vm.isFollowing = false;
vm.toggleMenuExpand = toggleMenuExpand;
vm.toggleLineExpand = toggleLineExpand;
vm.toggleCollapseAll = toggleCollapseAll;
vm.toggleCollapse = toggleCollapse;
vm.showHostDetails = showHostDetails;
vm.showMissingEvents = showMissingEvents;
vm.toggleLineEnabled = resource.model.get('type') === 'job';
vm.followTooltip = vm.strings.get('tooltips.MENU_LAST');
vm.debug = _debug;
render.requestAnimationFrame(() => {
bufferInit();
render.init({ compile, toggles: vm.toggleLineEnabled });
status.init(resource);
slide.init(render, resource.events, scroll);
render.init({ compile, toggles: vm.toggleLineEnabled });
page.init(resource.events);
slide.init(resource.events, render);
scroll.init({
next,
@@ -482,10 +783,29 @@ function OutputIndexController (
},
});
let showFollowTip = true;
const rates = [];
stream.init({
bufferAdd,
bufferEmpty,
onFrames,
onFrameRate (rate) {
rates.push(rate);
rates.splice(0, rates.length - 5);
if (rates.every(value => value === 1)) {
scroll.unlock();
scroll.unhide();
}
if (rate > 1 && vm.isFollowing) {
scroll.lock();
scroll.hide();
if (showFollowTip) {
showFollowTip = false;
$(OUTPUT_ELEMENT_LAST).trigger('mouseenter');
}
}
},
onStop () {
lockFollow = true;
stopFollowing();
@@ -493,11 +813,12 @@ function OutputIndexController (
status.updateStats();
status.dispatch();
status.sync();
scroll.stop();
scroll.unlock();
scroll.unhide();
}
});
if (resource.model.get('event_processing_finished')) {
if (isProcessingFinished) {
followOnce = false;
lockFollow = true;
lockFrames = true;
@@ -511,8 +832,21 @@ function OutputIndexController (
startListening();
}
if (_debug) {
return render.clear();
}
return last();
});
$scope.$on('$destroy', () => {
stopListening();
render.clear();
render.el.remove();
slide.clear();
stream.bufferInit();
});
}
OutputIndexController.$inject = [

View File

@@ -82,7 +82,7 @@ function resolveResource (
order_by: OUTPUT_ORDER_BY,
};
if (job_event_search) { // eslint-disable-line camelcase
if (job_event_search) {
const query = qs.encodeQuerysetObject(qs.decodeArr(job_event_search));
Object.assign(params, query);
}
@@ -173,7 +173,7 @@ function JobsRun ($stateRegistry, $filter, strings) {
const sanitize = $filter('sanitize');
const state = {
url: '/:type/:id?job_event_search',
url: '/:type/:id?job_event_search?_debug',
name: 'output',
parent,
ncyBreadcrumb,

View File

@@ -21,13 +21,14 @@
reload="vm.reloadState">
</at-job-search>
<div class="at-Stdout-menuTop">
<div class="pull-left" ng-click="vm.toggleMenuExpand()">
<div class="pull-left" ng-click="vm.toggleCollapseAll()">
<i class="at-Stdout-menuIcon fa" ng-if="vm.toggleLineEnabled"
ng-class="{ 'fa-minus': vm.isMenuExpanded, 'fa-plus': !vm.isMenuExpanded }"></i>
ng-class="{ 'fa-minus': !vm.isMenuCollapsed, 'fa-plus': vm.isMenuCollapsed }"></i>
</div>
<div class="pull-right" ng-click="vm.menu.last()">
<i class="at-Stdout-menuIcon--lg fa fa-angle-double-down"
ng-class="{ 'at-Stdout-menuIcon--active': vm.isFollowing }"
id="atStdoutMenuLast"
data-placement="top"
data-trigger="hover"
data-tip-watch="vm.followTooltip"
@@ -46,6 +47,9 @@
<i class="at-Stdout-menuIcon--lg fa fa-angle-up"
data-placement="top" aw-tool-tip="{{:: vm.strings.get('tooltips.MENU_UP') }}"></i>
</div>
<div class="pull-right" ng-if="vm.debug" ng-click="vm.menu.clear()">
<i class="at-Stdout-menuIcon--md fa fa-undo"></i>
</div>
<div class="at-u-clear"></div>
</div>
<div class="at-Stdout-container">

View File

@@ -2,244 +2,153 @@
import { OUTPUT_PAGE_LIMIT } from './constants';
function PageService ($q) {
this.init = (storage, api, { getScrollHeight }) => {
const { prepend, append, shift, pop, deleteRecord } = storage;
const { getPage, getFirst, getLast, getLastPageNumber, getMaxCounter } = api;
this.init = ({ getPage, getFirst, getLast, getLastPageNumber }) => {
this.api = {
getPage,
getFirst,
getLast,
getLastPageNumber,
getMaxCounter,
};
this.storage = {
prepend,
append,
shift,
pop,
deleteRecord,
};
this.hooks = {
getScrollHeight,
};
this.records = {};
this.uuids = {};
this.state = {
head: 0,
tail: 0,
};
this.chain = $q.resolve();
};
this.pushFront = (results, key) => {
if (!results) {
return $q.resolve();
}
return this.storage.append(results)
.then(() => {
const tail = key || ++this.state.tail;
this.records[tail] = {};
results.forEach(({ counter, start_line, end_line, uuid }) => {
this.records[tail][counter] = { start_line, end_line };
this.uuids[counter] = uuid;
});
return $q.resolve();
});
};
this.pushBack = (results, key) => {
if (!results) {
return $q.resolve();
}
return this.storage.prepend(results)
.then(() => {
const head = key || --this.state.head;
this.records[head] = {};
results.forEach(({ counter, start_line, end_line, uuid }) => {
this.records[head][counter] = { start_line, end_line };
this.uuids[counter] = uuid;
});
return $q.resolve();
});
};
this.popBack = () => {
if (this.getRecordCount() === 0) {
return $q.resolve();
}
const pageRecord = this.records[this.state.head] || {};
let lines = 0;
const counters = [];
Object.keys(pageRecord)
.forEach(counter => {
lines += pageRecord[counter].end_line - pageRecord[counter].start_line;
counters.push(counter);
});
return this.storage.shift(lines)
.then(() => {
counters.forEach(counter => {
this.storage.deleteRecord(this.uuids[counter]);
delete this.uuids[counter];
});
delete this.records[this.state.head++];
return $q.resolve();
});
};
this.popFront = () => {
if (this.getRecordCount() === 0) {
return $q.resolve();
}
const pageRecord = this.records[this.state.tail] || {};
let lines = 0;
const counters = [];
Object.keys(pageRecord)
.forEach(counter => {
lines += pageRecord[counter].end_line - pageRecord[counter].start_line;
counters.push(counter);
});
return this.storage.pop(lines)
.then(() => {
counters.forEach(counter => {
this.storage.deleteRecord(this.uuids[counter]);
delete this.uuids[counter];
});
delete this.records[this.state.tail--];
return $q.resolve();
});
this.pages = {};
this.state = { head: 0, tail: 0 };
};
this.getNext = () => {
const lastPageNumber = this.api.getLastPageNumber();
const number = Math.min(this.state.tail + 1, lastPageNumber);
const isLoaded = (number >= this.state.head && number <= this.state.tail);
const isValid = (number >= 1 && number <= lastPageNumber);
let popHeight = this.hooks.getScrollHeight();
if (!isValid || isLoaded) {
this.chain = this.chain
.then(() => $q.resolve(popHeight));
return this.chain;
if (number < 1) {
return $q.resolve([]);
}
const pageCount = this.state.head - this.state.tail;
if (pageCount >= OUTPUT_PAGE_LIMIT) {
this.chain = this.chain
.then(() => this.popBack())
.then(() => {
popHeight = this.hooks.getScrollHeight();
return $q.resolve();
});
if (number > lastPageNumber) {
return $q.resolve([]);
}
this.chain = this.chain
.then(() => this.api.getPage(number))
.then(events => this.pushFront(events))
.then(() => $q.resolve(popHeight));
let promise;
return this.chain;
if (this.pages[number]) {
promise = $q.resolve(this.pages[number]);
} else {
promise = this.api.getPage(number);
}
return promise
.then(results => {
if (results.length <= 0) {
return $q.resolve([]);
}
this.state.tail = number;
this.pages[number] = results;
return $q.resolve(results);
});
};
this.getPrevious = () => {
const number = Math.max(this.state.head - 1, 1);
const isLoaded = (number >= this.state.head && number <= this.state.tail);
const isValid = (number >= 1 && number <= this.api.getLastPageNumber());
let popHeight = this.hooks.getScrollHeight();
if (!isValid || isLoaded) {
this.chain = this.chain
.then(() => $q.resolve(popHeight));
return this.chain;
if (number < 1) {
return $q.resolve([]);
}
const pageCount = this.state.head - this.state.tail;
if (pageCount >= OUTPUT_PAGE_LIMIT) {
this.chain = this.chain
.then(() => this.popFront())
.then(() => {
popHeight = this.hooks.getScrollHeight();
return $q.resolve();
});
if (number > this.api.getLastPageNumber()) {
return $q.resolve([]);
}
this.chain = this.chain
.then(() => this.api.getPage(number))
.then(events => this.pushBack(events))
.then(() => $q.resolve(popHeight));
let promise;
return this.chain;
if (this.pages[number]) {
promise = $q.resolve(this.pages[number]);
} else {
promise = this.api.getPage(number);
}
return promise
.then(results => {
if (results.length <= 0) {
return $q.resolve([]);
}
this.state.head = number;
this.pages[number] = results;
return $q.resolve(results);
});
};
this.clear = () => {
const count = this.getRecordCount();
this.getLast = () => this.api.getLast()
.then(results => {
if (results.length <= 0) {
return $q.resolve([]);
}
for (let i = 0; i <= count; ++i) {
this.chain = this.chain.then(() => this.popBack());
}
const number = this.api.getLastPageNumber();
return this.chain;
};
this.state.head = number;
this.state.tail = number;
this.pages[number] = results;
this.getLast = () => this.clear()
.then(() => this.api.getLast())
.then(events => {
const lastPage = this.api.getLastPageNumber();
return $q.resolve(results);
});
this.state.head = lastPage;
this.state.tail = lastPage;
this.getFirst = () => this.api.getFirst()
.then(results => {
if (results.length <= 0) {
return $q.resolve([]);
}
return this.pushBack(events, lastPage);
})
.then(() => this.getPrevious());
this.getFirst = () => this.clear()
.then(() => this.api.getFirst())
.then(events => {
this.state.head = 1;
this.state.tail = 1;
this.pages[1] = results;
return this.pushBack(events, 1);
})
.then(() => this.getNext());
return $q.resolve(results);
});
this.isOnLastPage = () => this.api.getLastPageNumber() === this.state.tail;
this.getRecordCount = () => Object.keys(this.records).length;
this.getTailCounter = () => this.state.tail;
this.getMaxCounter = () => this.api.getMaxCounter();
this.trimTail = () => {
const { tail, head } = this.state;
let popCount = 0;
for (let i = tail; i > head; i--) {
if (!this.isOverCapacity()) {
break;
}
if (this.pages[i]) {
popCount += this.pages[i].length;
}
delete this.pages[i];
this.state.tail--;
}
return popCount;
};
this.trimHead = () => {
const { head, tail } = this.state;
let popCount = 0;
for (let i = head; i < tail; i++) {
if (!this.isOverCapacity()) {
break;
}
if (this.pages[i]) {
popCount += this.pages[i].length;
}
delete this.pages[i];
this.state.head++;
}
return popCount;
};
this.isOverCapacity = () => this.state.tail - this.state.head > OUTPUT_PAGE_LIMIT;
}
PageService.$inject = ['$q'];

View File

@@ -3,9 +3,12 @@ import Entities from 'html-entities';
import {
EVENT_START_PLAY,
EVENT_START_PLAYBOOK,
EVENT_STATS_PLAY,
EVENT_START_TASK,
OUTPUT_ANSI_COLORMAP,
OUTPUT_ELEMENT_TBODY,
OUTPUT_EVENT_LIMIT,
} from './constants';
const EVENT_GROUPS = [
@@ -19,7 +22,7 @@ const TIME_EVENTS = [
EVENT_STATS_PLAY,
];
const ansi = new Ansi();
const ansi = new Ansi({ stream: true, colors: OUTPUT_ANSI_COLORMAP });
const entities = new Entities.AllHtmlEntities();
// https://github.com/chalk/ansi-regex
@@ -33,98 +36,243 @@ const hasAnsi = input => re.test(input);
function JobRenderService ($q, $sce, $window) {
this.init = ({ compile, toggles }) => {
this.parent = null;
this.record = {};
this.el = $(OUTPUT_ELEMENT_TBODY);
this.hooks = { compile };
this.el = $(OUTPUT_ELEMENT_TBODY);
this.parent = null;
this.createToggles = toggles;
this.state = {
head: 0,
tail: 0,
collapseAll: false,
toggleMode: toggles,
};
this.records = {};
this.uuids = {};
};
this.sortByLineNumber = (a, b) => {
if (a.start_line > b.start_line) {
this.setCollapseAll = value => {
this.state.collapseAll = value;
Object.keys(this.records).forEach(key => {
this.records[key].isCollapsed = value;
});
};
this.sortByCounter = (a, b) => {
if (a.counter > b.counter) {
return 1;
}
if (a.start_line < b.start_line) {
if (a.counter < b.counter) {
return -1;
}
return 0;
};
this.transformEventGroup = events => {
//
// Event Data Transformation / HTML Building
//
this.appendEventGroup = events => {
let lines = 0;
let html = '';
events.sort(this.sortByLineNumber);
events.sort(this.sortByCounter);
for (let i = 0; i < events.length; ++i) {
const line = this.transformEvent(events[i]);
html += line.html;
lines += line.count;
for (let i = 0; i <= events.length - 1; i++) {
const current = events[i];
if (this.state.tail && current.counter !== this.state.tail + 1) {
const missing = this.appendMissingEventGroup(current);
html += missing.html;
lines += missing.count;
}
const eventLines = this.transformEvent(current);
html += eventLines.html;
lines += eventLines.count;
}
return { html, lines };
};
this.transformEvent = event => {
if (this.record[event.uuid]) {
this.appendMissingEventGroup = event => {
const tailUUID = this.uuids[this.state.tail];
const tailRecord = this.records[tailUUID];
if (!tailRecord) {
return { html: '', count: 0 };
}
if (!event || !event.stdout) {
let uuid;
if (tailRecord.isMissing) {
uuid = tailUUID;
} else {
uuid = `${event.counter}-${tailUUID}`;
this.records[uuid] = { uuid, counters: [], lineCount: 1, isMissing: true };
}
for (let i = this.state.tail + 1; i < event.counter; i++) {
this.records[uuid].counters.push(i);
this.uuids[i] = uuid;
}
if (tailRecord.isMissing) {
return { html: '', count: 0 };
}
if (tailRecord.end === event.start_line) {
return { html: '', count: 0 };
}
const html = this.buildRowHTML(this.records[uuid]);
const count = 1;
return { html, count };
};
this.prependEventGroup = events => {
let lines = 0;
let html = '';
events.sort(this.sortByCounter);
for (let i = events.length - 1; i >= 0; i--) {
const current = events[i];
if (this.state.head && current.counter !== this.state.head - 1) {
const missing = this.prependMissingEventGroup(current);
html = missing.html + html;
lines += missing.count;
}
const eventLines = this.transformEvent(current);
html = eventLines.html + html;
lines += eventLines.count;
}
return { html, lines };
};
this.prependMissingEventGroup = event => {
const headUUID = this.uuids[this.state.head];
const headRecord = this.records[headUUID];
if (!headRecord) {
return { html: '', count: 0 };
}
let uuid;
if (headRecord.isMissing) {
uuid = headUUID;
} else {
uuid = `${headUUID}-${event.counter}`;
this.records[uuid] = { uuid, counters: [], lineCount: 1, isMissing: true };
}
for (let i = this.state.head - 1; i > event.counter; i--) {
this.records[uuid].counters.unshift(i);
this.uuids[i] = uuid;
}
if (headRecord.isMissing) {
return { html: '', count: 0 };
}
if (event.end_line === headRecord.start) {
return { html: '', count: 0 };
}
const html = this.buildRowHTML(this.records[uuid]);
const count = 1;
return { html, count };
};
this.transformEvent = event => {
if (!event || event.stdout === null || event.stdout === undefined) {
return { html: '', count: 0 };
}
if (event.uuid && this.records[event.uuid]) {
return { html: '', count: 0 };
}
const stdout = this.sanitize(event.stdout);
const lines = stdout.split('\r\n');
const record = this.createRecord(event, lines);
if (event.event === EVENT_START_PLAYBOOK) {
return { html: '', count: 0 };
}
let html = '';
let count = lines.length;
let ln = event.start_line;
const current = this.createRecord(ln, lines, event);
const html = lines.reduce((concat, line, i) => {
for (let i = 0; i <= lines.length - 1; i++) {
ln++;
const line = lines[i];
const isLastLine = i === lines.length - 1;
let row = this.createRow(current, ln, line);
let row = this.buildRowHTML(record, ln, line);
if (current && current.isTruncated && isLastLine) {
row += this.createRow(current);
if (record && record.isTruncated && isLastLine) {
row += this.buildRowHTML(record);
count++;
}
return `${concat}${row}`;
}, '');
html += row;
}
if (this.records[event.uuid]) {
this.records[event.uuid].lineCount = count;
}
return { html, count };
};
this.isHostEvent = (event) => {
if (typeof event.host === 'number') {
return true;
}
if (event.type === 'project_update_event' &&
event.event !== 'runner_on_skipped' &&
event.event_data.host) {
return true;
}
return false;
};
this.createRecord = (ln, lines, event) => {
if (!event.uuid) {
this.createRecord = (event, lines) => {
if (!event.counter) {
return null;
}
const info = {
if (!this.state.head || event.counter < this.state.head) {
this.state.head = event.counter;
}
if (!this.state.tail || event.counter > this.state.tail) {
this.state.tail = event.counter;
}
if (!event.uuid) {
this.uuids[event.counter] = event.counter;
this.records[event.counter] = { counters: [event.counter], lineCount: lines.length };
return this.records[event.counter];
}
let isHost = false;
if (typeof event.host === 'number') {
isHost = true;
} else if (event.type === 'project_update_event' &&
event.event !== 'runner_on_skipped' &&
event.event_data.host) {
isHost = true;
}
const record = {
isHost,
id: event.id,
line: ln + 1,
line: event.start_line + 1,
name: event.event,
uuid: event.uuid,
level: event.event_level,
@@ -132,50 +280,49 @@ function JobRenderService ($q, $sce, $window) {
end: event.end_line,
isTruncated: (event.end_line - event.start_line) > lines.length,
lineCount: lines.length,
isHost: this.isHostEvent(event),
isCollapsed: this.state.collapseAll,
counters: [event.counter],
};
if (event.parent_uuid) {
info.parents = this.getParentEvents(event.parent_uuid);
record.parents = this.getParentEvents(event.parent_uuid);
if (this.records[event.parent_uuid]) {
record.isCollapsed = this.records[event.parent_uuid].isCollapsed;
}
}
if (info.isTruncated) {
info.truncatedAt = event.start_line + lines.length;
if (record.isTruncated) {
record.truncatedAt = event.start_line + lines.length;
}
if (EVENT_GROUPS.includes(event.event)) {
info.isParent = true;
record.isParent = true;
if (event.event_level === 1) {
this.parent = event.uuid;
}
if (event.parent_uuid) {
if (this.record[event.parent_uuid]) {
if (this.record[event.parent_uuid].children &&
!this.record[event.parent_uuid].children.includes(event.uuid)) {
this.record[event.parent_uuid].children.push(event.uuid);
if (this.records[event.parent_uuid]) {
if (this.records[event.parent_uuid].children &&
!this.records[event.parent_uuid].children.includes(event.uuid)) {
this.records[event.parent_uuid].children.push(event.uuid);
} else {
this.record[event.parent_uuid].children = [event.uuid];
this.records[event.parent_uuid].children = [event.uuid];
}
}
}
}
if (TIME_EVENTS.includes(event.event)) {
info.time = this.getTimestamp(event.created);
info.line++;
record.time = this.getTimestamp(event.created);
record.line++;
}
this.record[event.uuid] = info;
this.records[event.uuid] = record;
this.uuids[event.counter] = event.uuid;
return info;
};
this.getRecord = uuid => this.record[uuid];
this.deleteRecord = uuid => {
delete this.record[uuid];
return record;
};
this.getParentEvents = (uuid, list) => {
@@ -183,42 +330,56 @@ function JobRenderService ($q, $sce, $window) {
// always push its parent if exists
list.push(uuid);
// if we can get grandparent in current visible lines, we also push it
if (this.record[uuid] && this.record[uuid].parents) {
list = list.concat(this.record[uuid].parents);
if (this.records[uuid] && this.records[uuid].parents) {
list = list.concat(this.records[uuid].parents);
}
return list;
};
this.createRow = (current, ln, content) => {
this.buildRowHTML = (record, ln, content) => {
let id = '';
let icon = '';
let timestamp = '';
let tdToggle = '';
let tdEvent = '';
let classList = '';
if (record.isMissing) {
return `<div id="${record.uuid}" class="at-Stdout-row">
<div class="at-Stdout-toggle"></div>
<div class="at-Stdout-line at-Stdout-line--clickable" ng-click="vm.showMissingEvents('${record.uuid}')">...</div></div>`;
}
content = content || '';
if (hasAnsi(content)) {
content = ansi.toHtml(content);
}
if (current) {
if (this.createToggles && current.isParent && current.line === ln) {
id = current.uuid;
tdToggle = `<div class="at-Stdout-toggle" ng-click="vm.toggleLineExpand('${id}')"><i class="fa fa-angle-down can-toggle"></i></div>`;
if (record) {
if (this.state.toggleMode && record.isParent && record.line === ln) {
id = record.uuid;
if (record.isCollapsed) {
icon = 'fa-angle-right';
} else {
icon = 'fa-angle-down';
}
tdToggle = `<div class="at-Stdout-toggle" ng-click="vm.toggleCollapse('${id}')"><i class="fa ${icon} can-toggle"></i></div>`;
}
if (current.isHost) {
tdEvent = `<div class="at-Stdout-event--host" ng-click="vm.showHostDetails('${current.id}', '${current.uuid}')"><span ng-non-bindable>${content}</span></div>`;
if (record.isHost) {
tdEvent = `<div class="at-Stdout-event--host" ng-click="vm.showHostDetails('${record.id}', '${record.uuid}')"><span ng-non-bindable>${content}</span></div>`;
}
if (current.time && current.line === ln) {
timestamp = `<span>${current.time}</span>`;
if (record.time && record.line === ln) {
timestamp = `<span>${record.time}</span>`;
}
if (current.parents) {
classList = current.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, '');
if (record.parents) {
classList = record.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, '');
}
}
@@ -234,6 +395,12 @@ function JobRenderService ($q, $sce, $window) {
ln = '...';
}
if (record && record.isCollapsed) {
if (record.level === 3 || record.level === 0) {
classList += ' hidden';
}
}
return `
<div id="${id}" class="at-Stdout-row ${classList}">
${tdToggle}
@@ -252,6 +419,10 @@ function JobRenderService ($q, $sce, $window) {
return `${hour}:${minute}:${second}`;
};
//
// Element Operations
//
this.remove = elements => this.requestAnimationFrame(() => elements.remove());
this.requestAnimationFrame = fn => $q(resolve => {
@@ -270,19 +441,25 @@ function JobRenderService ($q, $sce, $window) {
return this.requestAnimationFrame();
};
this.clear = () => {
const elements = this.el.children();
this.removeAll = () => {
const elements = this.el.contents();
return this.remove(elements);
};
this.shift = lines => {
const elements = this.el.children().slice(0, lines);
// We multiply by two here under the assumption that one element and one text node
// is generated for each line of output.
const count = 2 * lines;
const elements = this.el.contents().slice(0, count);
return this.remove(elements);
};
this.pop = lines => {
const elements = this.el.children().slice(-lines);
// We multiply by two here under the assumption that one element and one text node
// is generated for each line of output.
const count = 2 * lines;
const elements = this.el.contents().slice(-count);
return this.remove(elements);
};
@@ -292,7 +469,7 @@ function JobRenderService ($q, $sce, $window) {
return $q.resolve();
}
const result = this.transformEventGroup(events);
const result = this.prependEventGroup(events);
const html = this.trustHtml(result.html);
const newElements = angular.element(html);
@@ -307,7 +484,7 @@ function JobRenderService ($q, $sce, $window) {
return $q.resolve();
}
const result = this.transformEventGroup(events);
const result = this.appendEventGroup(events);
const html = this.trustHtml(result.html);
const newElements = angular.element(html);
@@ -318,8 +495,110 @@ function JobRenderService ($q, $sce, $window) {
};
this.trustHtml = html => $sce.getTrustedHtml($sce.trustAsHtml(html));
this.sanitize = html => entities.encode(html);
//
// Event Counter Methods - External code should prefer these.
//
this.clear = () => this.removeAll()
.then(() => {
const head = this.getHeadCounter();
const tail = this.getTailCounter();
for (let i = head; i <= tail; ++i) {
const uuid = this.uuids[i];
if (uuid) {
delete this.records[uuid];
delete this.uuids[i];
}
}
this.state.head = 0;
this.state.tail = 0;
return $q.resolve();
});
this.pushFront = events => {
const tail = this.getTailCounter();
return this.append(events.filter(({ counter }) => counter > tail));
};
this.pushBack = events => {
const head = this.getHeadCounter();
const tail = this.getTailCounter();
return this.prepend(events.filter(({ counter }) => counter < head || counter > tail));
};
this.popFront = count => {
if (!count || count <= 0) {
return $q.resolve();
}
const max = this.state.tail;
const min = max - count;
let lines = 0;
for (let i = max; i >= min; --i) {
const uuid = this.uuids[i];
if (!uuid) {
continue;
}
this.records[uuid].counters.pop();
delete this.uuids[i];
if (this.records[uuid].counters.length === 0) {
lines += this.records[uuid].lineCount;
delete this.records[uuid];
this.state.tail--;
}
}
return this.pop(lines);
};
this.popBack = count => {
if (!count || count <= 0) {
return $q.resolve();
}
const min = this.state.head;
const max = min + count;
let lines = 0;
for (let i = min; i <= max; ++i) {
const uuid = this.uuids[i];
if (!uuid) {
continue;
}
this.records[uuid].counters.shift();
delete this.uuids[i];
if (this.records[uuid].counters.length === 0) {
lines += this.records[uuid].lineCount;
delete this.records[uuid];
this.state.head++;
}
}
return this.shift(lines);
};
this.getHeadCounter = () => this.state.head;
this.getTailCounter = () => this.state.tail;
this.getCapacity = () => OUTPUT_EVENT_LIMIT - (this.getTailCounter() - this.getHeadCounter());
}
JobRenderService.$inject = ['$q', '$sce', '$window'];

View File

@@ -5,8 +5,6 @@ import {
OUTPUT_SCROLL_THRESHOLD,
} from './constants';
const MAX_THRASH = 20;
function JobScrollService ($q, $timeout) {
this.init = ({ next, previous, onThresholdLeave }) => {
this.el = $(OUTPUT_ELEMENT_CONTAINER);
@@ -33,7 +31,6 @@ function JobScrollService ($q, $timeout) {
paused: false,
locked: false,
hover: false,
running: true,
thrash: 0,
};
@@ -44,13 +41,6 @@ function JobScrollService ($q, $timeout) {
this.onMouseEnter = () => {
this.state.hover = true;
if (this.state.thrash >= MAX_THRASH) {
this.state.thrash = MAX_THRASH - 1;
}
this.unlock();
this.unhide();
};
this.onMouseLeave = () => {
@@ -62,23 +52,6 @@ function JobScrollService ($q, $timeout) {
return;
}
if (this.state.thrash > 0) {
if (this.isLocked() || this.state.hover) {
this.state.thrash--;
}
}
if (!this.state.hover) {
this.state.thrash++;
}
if (this.state.thrash >= MAX_THRASH) {
if (this.isRunning()) {
this.lock();
this.hide();
}
}
if (this.isLocked()) {
return;
}
@@ -195,16 +168,6 @@ function JobScrollService ($q, $timeout) {
this.setScrollPosition(this.getScrollHeight());
};
this.start = () => {
this.state.running = true;
};
this.stop = () => {
this.unlock();
this.unhide();
this.state.running = false;
};
this.lock = () => {
this.state.locked = true;
};
@@ -256,7 +219,6 @@ function JobScrollService ($q, $timeout) {
};
this.isPaused = () => this.state.paused;
this.isRunning = () => this.state.running;
this.isLocked = () => this.state.locked;
this.isMissing = () => $(OUTPUT_ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight();
}

View File

@@ -1,42 +1,12 @@
/* eslint camelcase: 0 */
import {
API_MAX_PAGE_SIZE,
OUTPUT_EVENT_LIMIT,
OUTPUT_MAX_BUFFER_LENGTH,
OUTPUT_PAGE_SIZE,
} from './constants';
function getContinuous (events, reverse = false) {
const counters = events.map(({ counter }) => counter);
const min = Math.min(...counters);
const max = Math.max(...counters);
const missing = [];
for (let i = min; i <= max; i++) {
if (counters.indexOf(i) < 0) {
missing.push(i);
}
}
if (missing.length === 0) {
return events;
}
if (reverse) {
const threshold = Math.max(...missing);
return events.filter(({ counter }) => counter > threshold);
}
const threshold = Math.min(...missing);
return events.filter(({ counter }) => counter < threshold);
}
function SlidingWindowService ($q) {
this.init = (storage, api, { getScrollHeight }) => {
const { prepend, append, shift, pop, getRecord, deleteRecord, clear } = storage;
const { getRange, getFirst, getLast, getMaxCounter } = api;
this.init = ({ getRange, getFirst, getLast, getMaxCounter }, storage) => {
const { getHeadCounter, getTailCounter } = storage;
this.api = {
getRange,
@@ -46,32 +16,20 @@ function SlidingWindowService ($q) {
};
this.storage = {
clear,
prepend,
append,
shift,
pop,
getRecord,
deleteRecord,
getHeadCounter,
getTailCounter,
};
this.hooks = {
getScrollHeight,
};
this.lines = {};
this.uuids = {};
this.chain = $q.resolve();
this.state = { head: null, tail: null };
this.cache = { first: null };
this.buffer = {
events: [],
min: 0,
max: 0,
count: 0,
};
this.cache = {
first: null
};
};
this.getBoundedRange = range => {
@@ -92,273 +50,46 @@ function SlidingWindowService ($q) {
return this.getBoundedRange([head - 1 - displacement, head - 1]);
};
this.createRecord = ({ counter, uuid, start_line, end_line }) => {
this.lines[counter] = end_line - start_line;
this.uuids[counter] = uuid;
if (this.state.tail === null) {
this.state.tail = counter;
}
if (counter > this.state.tail) {
this.state.tail = counter;
}
if (this.state.head === null) {
this.state.head = counter;
}
if (counter < this.state.head) {
this.state.head = counter;
}
};
this.deleteRecord = counter => {
this.storage.deleteRecord(this.uuids[counter]);
delete this.uuids[counter];
delete this.lines[counter];
};
this.getLineCount = counter => {
const record = this.storage.getRecord(counter);
if (record && record.lineCount) {
return record.lineCount;
}
if (this.lines[counter]) {
return this.lines[counter];
}
return 0;
};
this.pushFront = events => {
const tail = this.getTailCounter();
const newEvents = events.filter(({ counter }) => counter > tail);
return this.storage.append(newEvents)
.then(() => {
newEvents.forEach(event => this.createRecord(event));
return $q.resolve();
});
};
this.pushBack = events => {
const [head, tail] = this.getRange();
const newEvents = events
.filter(({ counter }) => counter < head || counter > tail);
return this.storage.prepend(newEvents)
.then(() => {
newEvents.forEach(event => this.createRecord(event));
return $q.resolve();
});
};
this.popFront = count => {
if (!count || count <= 0) {
return $q.resolve();
}
const max = this.getTailCounter();
const min = max - count;
let lines = 0;
for (let i = max; i >= min; --i) {
lines += this.getLineCount(i);
}
return this.storage.pop(lines)
.then(() => {
for (let i = max; i >= min; --i) {
this.deleteRecord(i);
this.state.tail--;
}
return $q.resolve();
});
};
this.popBack = count => {
if (!count || count <= 0) {
return $q.resolve();
}
const min = this.getHeadCounter();
const max = min + count;
let lines = 0;
for (let i = min; i <= max; ++i) {
lines += this.getLineCount(i);
}
return this.storage.shift(lines)
.then(() => {
for (let i = min; i <= max; ++i) {
this.deleteRecord(i);
this.state.head++;
}
return $q.resolve();
});
};
this.clear = () => this.storage.clear()
.then(() => {
const [head, tail] = this.getRange();
for (let i = head; i <= tail; ++i) {
this.deleteRecord(i);
}
this.state.head = null;
this.state.tail = null;
return $q.resolve();
});
this.getNext = (displacement = OUTPUT_PAGE_SIZE) => {
const next = this.getNextRange(displacement);
const [head, tail] = this.getRange();
this.chain = this.chain
.then(() => this.api.getRange(next))
.then(events => {
const results = getContinuous(events);
const min = Math.min(...results.map(({ counter }) => counter));
if (min > tail + 1) {
return $q.resolve([]);
}
return $q.resolve(results);
})
.then(results => {
const count = (tail - head + results.length);
const excess = count - OUTPUT_EVENT_LIMIT;
return this.popBack(excess)
.then(() => {
const popHeight = this.hooks.getScrollHeight();
return this.pushFront(results).then(() => $q.resolve(popHeight));
});
});
return this.chain;
return this.api.getRange(next);
};
this.getPrevious = (displacement = OUTPUT_PAGE_SIZE) => {
const previous = this.getPreviousRange(displacement);
const [head, tail] = this.getRange();
this.chain = this.chain
.then(() => this.api.getRange(previous))
.then(events => {
const results = getContinuous(events, true);
const max = Math.max(...results.map(({ counter }) => counter));
if (head > max + 1) {
return $q.resolve([]);
}
return $q.resolve(results);
})
.then(results => {
const count = (tail - head + results.length);
const excess = count - OUTPUT_EVENT_LIMIT;
return this.popFront(excess)
.then(() => {
const popHeight = this.hooks.getScrollHeight();
return this.pushBack(results).then(() => $q.resolve(popHeight));
});
});
return this.chain;
return this.api.getRange(previous);
};
this.getFirst = () => {
this.chain = this.chain
.then(() => this.clear())
.then(() => {
if (this.cache.first) {
return $q.resolve(this.cache.first);
}
if (this.cache.first) {
return $q.resolve(this.cache.first);
}
return this.api.getFirst();
})
return this.api.getFirst()
.then(events => {
if (events.length === OUTPUT_PAGE_SIZE) {
this.cache.first = events;
}
return this.pushFront(events);
return $q.resolve(events);
});
return this.chain
.then(() => this.getNext());
};
this.getLast = () => {
this.chain = this.chain
.then(() => this.getFrames())
.then(frames => {
if (frames.length > 0) {
return $q.resolve(frames);
}
this.getLast = () => this.getFrames()
.then(frames => {
if (frames.length > 0) {
return $q.resolve(frames);
}
return this.api.getLast();
})
.then(events => {
const min = Math.min(...events.map(({ counter }) => counter));
if (min <= this.getTailCounter() + 1) {
return this.pushFront(events);
}
return this.clear()
.then(() => this.pushBack(events));
});
return this.chain
.then(() => this.getPrevious());
};
this.getTailCounter = () => {
if (this.state.tail === null) {
return 0;
}
if (this.state.tail < 0) {
return 0;
}
return this.state.tail;
};
this.getHeadCounter = () => {
if (this.state.head === null) {
return 0;
}
if (this.state.head < 0) {
return 0;
}
return this.state.head;
};
return this.api.getLast();
});
this.pushFrames = events => {
const head = this.getHeadCounter();
const tail = this.getTailCounter();
const frames = this.buffer.events.concat(events);
const [head, tail] = this.getRange();
let min;
let max;
@@ -367,7 +98,7 @@ function SlidingWindowService ($q) {
for (let i = frames.length - 1; i >= 0; i--) {
count++;
if (count > API_MAX_PAGE_SIZE) {
if (count > OUTPUT_MAX_BUFFER_LENGTH) {
frames.splice(i, 1);
count--;
@@ -388,27 +119,41 @@ function SlidingWindowService ($q) {
this.buffer.max = max;
this.buffer.count = count;
if (min >= head && min <= tail + 1) {
return frames.filter(({ counter }) => counter > tail);
if (tail - head === 0) {
return frames;
}
return [];
return frames.filter(({ counter }) => counter > tail);
};
this.clear = () => {
this.buffer.events.length = 0;
this.buffer.min = 0;
this.buffer.max = 0;
this.buffer.count = 0;
};
this.getFrames = () => $q.resolve(this.buffer.events);
this.getMaxCounter = () => {
if (this.buffer.min) {
return this.buffer.min;
if (this.buffer.max && this.buffer.max > 1) {
return this.buffer.max;
}
return this.api.getMaxCounter();
};
this.isOnLastPage = () => this.getTailCounter() >= (this.getMaxCounter() - OUTPUT_PAGE_SIZE);
this.getRange = () => [this.getHeadCounter(), this.getTailCounter()];
this.getRecordCount = () => Object.keys(this.lines).length;
this.getCapacity = () => OUTPUT_EVENT_LIMIT - this.getRecordCount();
this.isOnLastPage = () => {
if (this.buffer.min) {
return this.getTailCounter() >= this.buffer.min - 1;
}
return this.getTailCounter() >= this.getMaxCounter() - OUTPUT_PAGE_SIZE;
};
this.isOnFirstPage = () => this.getHeadCounter() === 1;
this.getTailCounter = () => this.storage.getTailCounter();
this.getHeadCounter = () => this.storage.getHeadCounter();
}
SlidingWindowService.$inject = ['$q'];

View File

@@ -1,3 +1,5 @@
import { OUTPUT_NO_COUNT_JOB_TYPES } from './constants';
const templateUrl = require('~features/output/stats.partial.html');
let vm;
@@ -21,6 +23,7 @@ function JobStatsController (strings, { subscribe }) {
};
vm.$onInit = () => {
vm.hideCounts = OUTPUT_NO_COUNT_JOB_TYPES.includes(vm.resource.model.get('type'));
vm.download = vm.resource.model.get('related.stdout');
vm.tooltips.toggleExpand = vm.expanded ?
strings.get('tooltips.COLLAPSE_OUTPUT') :

View File

@@ -1,20 +1,20 @@
<!-- todo: styling, markup, css etc. - disposition according to project lib conventions -->
<div class="at-u-floatRight">
<span class="at-Panel-label">plays</span>
<span ng-show="vm.running" class="at-Panel-headingTitleBadge">...</span>
<span ng-show="!vm.running" class="at-Panel-headingTitleBadge">{{ vm.plays || 0 }}</span>
<span ng-show="!vm.hideCounts" class="at-Panel-label">plays</span>
<span ng-show="!vm.hideCounts && vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">...</span>
<span ng-show="!vm.hideCounts && !vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">{{ vm.plays || 0 }}</span>
<span class="at-Panel-label">tasks</span>
<span ng-show="vm.running" class="at-Panel-headingTitleBadge">...</span>
<span ng-show="!vm.running" class="at-Panel-headingTitleBadge">{{ vm.tasks || 0 }}</span>
<span ng-show="!vm.hideCounts" class="at-Panel-label">tasks</span>
<span ng-show="!vm.hideCounts && vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">...</span>
<span ng-show="!vm.hideCounts && !vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">{{ vm.tasks || 0 }}</span>
<span class="at-Panel-label">{{:: vm.strings.get('stats.HOSTS')}}</span>
<span ng-show="vm.running" class="at-Panel-headingTitleBadge">...</span>
<span ng-show="!vm.running" class="at-Panel-headingTitleBadge">{{ vm.hosts || 1 }}</span>
<span ng-show="!vm.hideCounts" class="at-Panel-label">{{:: vm.strings.get('stats.HOSTS')}}</span>
<span ng-show="!vm.hideCounts && vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">...</span>
<span ng-show="!vm.hideCounts && !vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">{{ vm.hosts || 1 }}</span>
<span class="at-Panel-label">{{:: vm.strings.get('stats.ELAPSED') }}</span>
<span ng-show="vm.running" class="at-Panel-headingTitleBadge">...</span>
<span ng-show="!vm.running" class="at-Panel-headingTitleBadge">
<span ng-show="vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">...</span>
<span ng-show="!vm.running" class="at-Panel-headingTitleBadge at-Panel-headingTitleBadge--inline">
{{ (vm.elapsed * 1000 || 0) | duration: "hh:mm:ss"}}
</span>

View File

@@ -1,36 +1,49 @@
/* eslint camelcase: 0 */
import {
EVENT_STATS_PLAY,
OUTPUT_MAX_BUFFER_LENGTH,
OUTPUT_MAX_LAG,
OUTPUT_PAGE_SIZE,
OUTPUT_EVENT_LIMIT,
} from './constants';
const rx = [];
function OutputStream ($q) {
this.init = ({ bufferAdd, bufferEmpty, onFrames, onStop }) => {
this.init = ({ onFrames, onFrameRate, onStop }) => {
this.hooks = {
bufferAdd,
bufferEmpty,
onFrames,
onFrameRate,
onStop,
};
this.bufferInit();
};
this.bufferInit = () => {
rx.length = 0;
this.counters = {
used: [],
ready: [],
min: 1,
max: 0,
max: -1,
ready: -1,
final: null,
used: [],
missing: [],
total: 0,
length: 0,
};
this.state = {
ending: false,
ended: false,
overflow: false,
};
this.lag = 0;
this.chain = $q.resolve();
this.factors = this.calcFactors(OUTPUT_PAGE_SIZE);
this.factors = this.calcFactors(OUTPUT_EVENT_LIMIT);
this.setFramesPerRender();
};
@@ -53,6 +66,7 @@ function OutputStream ($q) {
const boundedIndex = Math.min(this.factors.length - 1, index);
this.framesPerRender = this.factors[boundedIndex];
this.hooks.onFrameRate(this.framesPerRender);
};
this.setMissingCounterThreshold = counter => {
@@ -61,36 +75,87 @@ function OutputStream ($q) {
}
};
this.updateCounterState = ({ counter }) => {
this.counters.used.push(counter);
this.bufferAdd = event => {
const { counter } = event;
if (counter > this.counters.max) {
this.counters.max = counter;
}
let ready;
const used = [];
const missing = [];
let minReady;
let maxReady;
for (let i = this.counters.min; i <= this.counters.max; i++) {
if (this.counters.used.indexOf(i) === -1) {
missing.push(i);
} else if (missing.length === 0) {
maxReady = i;
if (i === counter) {
rx.push(event);
used.push(i);
this.counters.length += 1;
} else {
missing.push(i);
}
} else {
used.push(i);
}
}
if (maxReady) {
minReady = this.counters.min;
const excess = this.counters.length - OUTPUT_MAX_BUFFER_LENGTH;
this.state.overflow = (excess > 0);
this.counters.min = maxReady + 1;
this.counters.used = this.counters.used.filter(c => c > maxReady);
if (missing.length === 0) {
ready = this.counters.max;
} else if (this.state.overflow) {
ready = this.counters.min + this.framesPerRender;
} else {
ready = missing[0] - 1;
}
this.counters.total += 1;
this.counters.ready = ready;
this.counters.used = used;
this.counters.missing = missing;
this.counters.ready = [minReady, maxReady];
};
return this.counters.ready;
this.bufferEmpty = threshold => {
let removed = [];
for (let i = rx.length - 1; i >= 0; i--) {
if (rx[i].counter <= threshold) {
removed = removed.concat(rx.splice(i, 1));
}
}
this.counters.min = threshold + 1;
this.counters.used = this.counters.used.filter(c => c > threshold);
this.counters.length = rx.length;
return removed;
};
this.isReadyToRender = () => {
const { total } = this.counters;
const readyCount = this.counters.ready - this.counters.min;
if (readyCount <= 0) {
return false;
}
if (this.state.ending) {
return true;
}
if (total % this.framesPerRender === 0) {
return true;
}
if (total < OUTPUT_PAGE_SIZE) {
if (readyCount % this.framesPerRender === 0) {
return true;
}
}
return false;
};
this.pushJobEvent = data => {
@@ -103,24 +168,24 @@ function OutputStream ($q) {
this.counters.final = data.counter;
}
const [minReady, maxReady] = this.updateCounterState(data);
const count = this.hooks.bufferAdd(data);
this.bufferAdd(data);
if (count % OUTPUT_PAGE_SIZE === 0) {
if (this.counters.total % OUTPUT_PAGE_SIZE === 0) {
this.setFramesPerRender();
}
const isReady = maxReady && (this.state.ending ||
(maxReady - minReady) % this.framesPerRender === 0);
if (!isReady) {
if (!this.isReadyToRender()) {
return $q.resolve();
}
const isLastFrame = this.state.ending && (maxReady >= this.counters.final);
const events = this.hooks.bufferEmpty(minReady, maxReady);
const isLast = this.state.ending && (this.counters.ready >= this.counters.final);
const events = this.bufferEmpty(this.counters.ready);
return this.emitFrames(events, isLastFrame);
if (events.length > 0) {
return this.emitFrames(events, isLast);
}
return $q.resolve();
})
.then(() => --this.lag);
@@ -133,16 +198,20 @@ function OutputStream ($q) {
this.state.ending = true;
this.counters.final = counter;
if (counter >= this.counters.min) {
if (counter > this.counters.ready) {
return $q.resolve();
}
const readyCount = this.counters.ready - this.counters.min;
let events = [];
if (this.counters.ready.length > 0) {
events = this.hooks.bufferEmpty(...this.counters.ready);
if (readyCount > 0) {
events = this.bufferEmpty(this.counters.ready);
return this.emitFrames(events, true);
}
return this.emitFrames(events, true);
return $q.resolve();
});
return this.chain;
@@ -157,7 +226,6 @@ function OutputStream ($q) {
this.hooks.onStop();
}
this.counters.ready.length = 0;
return $q.resolve();
});

View File

@@ -1,9 +0,0 @@
/** @define TokenModal */
.TokenModal {
display: flex;
}
.TokenModal-label {
font-weight: bold;
width: 130px;
}

View File

@@ -58,30 +58,30 @@ function AddTokensController (
return postToken
.then(({ data }) => {
const refreshHTML = data.refresh_token ?
`<div class="TokenModal">
<div class="TokenModal-label">
`<div class="PopupModal">
<div class="PopupModal-label">
${strings.get('add.REFRESH_TOKEN_LABEL')}
</div>
<div class="TokenModal-value">
<div class="PopupModal-value">
${data.refresh_token}
</div>
</div>` : '';
Alert(strings.get('add.TOKEN_MODAL_HEADER'), `
<div class="TokenModal">
<div class="TokenModal-label">
<div class="PopupModal">
<div class="PopupModal-label">
${strings.get('add.TOKEN_LABEL')}
</div>
<div class="TokenModal-value">
<div class="PopupModal-value">
${data.token}
</div>
</div>
${refreshHTML}
<div class="TokenModal">
<div class="TokenModal-label">
<div class="PopupModal">
<div class="PopupModal-label">
${strings.get('add.TOKEN_EXPIRES_LABEL')}
</div>
<div class="TokenModal-value">
<div class="PopupModal-value">
${$filter('longDate')(data.expires)}
</div>
</div>

View File

@@ -16,6 +16,7 @@ function AtLayoutController ($scope, $http, strings, ProcessErrors, $transitions
if (!vm.isSuperUser) {
checkOrgAdmin();
checkNotificationAdmin();
}
}
});
@@ -54,6 +55,24 @@ function AtLayoutController ($scope, $http, strings, ProcessErrors, $transitions
});
});
}
function checkNotificationAdmin () {
const usersPath = `/api/v2/users/${vm.currentUserId}/roles/?role_field=notification_admin_role`;
$http.get(usersPath)
.then(({ data }) => {
if (data.count > 0) {
vm.isNotificationAdmin = true;
} else {
vm.isNotificationAdmin = false;
}
})
.catch(({ data, status }) => {
ProcessErrors(null, data, status, null, {
hdr: strings.get('error.HEADER'),
msg: strings.get('error.CALL', { path: usersPath, action: 'GET', status })
});
});
}
}
AtLayoutController.$inject = ['$scope', '$http', 'ComponentsStrings', 'ProcessErrors', '$transitions'];

View File

@@ -81,10 +81,10 @@
<span>
</div>
<at-side-nav-item icon-class="fa-list-alt" route="credentialTypes" name="CREDENTIAL_TYPES"
system-admin-only="true">
system-admin-only="true">
</at-side-nav-item>
<at-side-nav-item icon-class="fa-bell" route="notifications" name="NOTIFICATIONS"
system-admin-only="true">
ng-show="$parent.layoutVm.isSuperUser || $parent.layoutVm.isOrgAdmin || $parent.layoutVm.isNotificationAdmin">
</at-side-nav-item>
<at-side-nav-item icon-class="fa-briefcase" route="managementJobsList" name="MANAGEMENT_JOBS"
system-admin-only="true">

View File

@@ -46,6 +46,11 @@
text-align: center;
margin-left: 10px;
margin-right: auto;
&--inline {
margin-right: @at-space-2x;
margin-left: 0;
}
}
.at-Panel-headingCustomContent {
@@ -59,6 +64,7 @@
font-size: 12px;
font-weight: normal!important;
width: 30%;
margin: @at-space-2x;
@media screen and (max-width: @breakpoint-md) {
flex: 2.5 0 auto;

View File

@@ -88,7 +88,7 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'Pr
};
scope.hasSelectedRows = function(){
return _.any(scope.allSelected, (type) => Object.keys(type).length > 0);
return _.some(scope.allSelected, (type) => Object.keys(type).length > 0);
};
scope.selectTab = function(selected){

View File

@@ -94,7 +94,7 @@ function(scope, $state, i18n, CreateSelect2, Rest, $q, Wait, ProcessErrors) {
};
scope.showSection2Container = function(){
return _.any(scope.allSelected, (type) => Object.keys(type).length > 0);
return _.some(scope.allSelected, (type) => Object.keys(type).length > 0);
};
scope.showSection2Tab = function(tab){

View File

@@ -85,6 +85,9 @@ export default function BuildAnchor($log, $filter) {
const inventoryId = _.get(obj, 'inventory', '').split('-').reverse()[0];
url += `inventories/inventory/${inventoryId}/inventory_sources/edit/${obj.id}`;
break;
case 'o_auth2_application':
url += `applications/${obj.id}`;
break;
default:
url += resource + 's/' + obj.id + '/';
}

View File

@@ -349,7 +349,7 @@ angular
$rootScope.$broadcast("RemoveIndicator");
}
if(_.contains(trans.from().name, 'output') && trans.to().name === 'jobs'){
if(_.includes(trans.from().name, 'output') && trans.to().name === 'jobs'){
$state.reload();
}
});
@@ -375,7 +375,7 @@ angular
$rootScope.user_is_system_auditor = Authorization.getUserInfo('is_system_auditor');
// state the user refreshes we want to open the socket, except if the user is on the login page, which should happen after the user logs in (see the AuthService module for that call to OpenSocket)
if (!_.contains($location.$$url, '/login')) {
if (!_.includes($location.$$url, '/login')) {
ConfigService.getConfig().then(function() {
Timer.init().then(function(timer) {
$rootScope.sessionTimer = timer;

View File

@@ -90,7 +90,7 @@ export default
if(streamConfig && streamConfig.activityStream) {
if(streamConfig.activityStreamTarget) {
stateGoParams.target = streamConfig.activityStreamTarget;
let isTemplateTarget = _.contains(['template', 'job_template', 'workflow_job_template'], streamConfig.activityStreamTarget);
let isTemplateTarget = _.includes(['template', 'job_template', 'workflow_job_template'], streamConfig.activityStreamTarget);
stateGoParams.activity_search = {
or__object1__in: isTemplateTarget ? 'job_template,workflow_job_template' : streamConfig.activityStreamTarget,
or__object2__in: isTemplateTarget ? 'job_template,workflow_job_template' : streamConfig.activityStreamTarget,

View File

@@ -25,7 +25,7 @@ export default
if(expandedBreadcrumbWidth > availableWidth) {
let widthToTrim = expandedBreadcrumbWidth - availableWidth;
// Sort the crumbs from biggest to smallest
let sortedCrumbs = _.sortByOrder(crumbs, ["origWidth"], ["desc"]);
let sortedCrumbs = _.orderBy(crumbs, ["origWidth"], ["desc"]);
let maxWidth;
for(let i=0; i<sortedCrumbs.length; i++) {
if(sortedCrumbs[i+1]) {

View File

@@ -252,7 +252,7 @@ export default [
},
];
var forms = _.pluck(authForms, 'formDef');
var forms = _.map(authForms, 'formDef');
_.each(forms, function(form) {
var keys = _.keys(form.fields);
_.each(keys, function(key) {

View File

@@ -119,7 +119,7 @@ export default [
}));
$('.select2-selection__choice').each(function(i, element){
if(!_.contains($scope.$parent.configDataResolve.AD_HOC_COMMANDS.default, element.title)){
if(!_.includes($scope.$parent.configDataResolve.AD_HOC_COMMANDS.default, element.title)){
$(`#configuration_jobs_template_AD_HOC_COMMANDS option[value='${element.title}']`).remove();
element.remove();
}

View File

@@ -106,7 +106,7 @@ export default [
id: 'system-misc-form'
}];
var forms = _.pluck(systemForms, 'formDef');
var forms = _.map(systemForms, 'formDef');
_.each(forms, function(form) {
var keys = _.keys(form.fields);
_.each(keys, function(key) {

View File

@@ -27,7 +27,7 @@ export default ['i18n', function(i18n) {
SESSION_COOKIE_AGE: {
type: 'number',
integer: true,
min: 60,
min: 61,
reset: 'SESSION_COOKIE_AGE',
},
SESSIONS_PER_USER: {

View File

@@ -17,8 +17,8 @@ function CapacityAdjuster (templateUrl, ProcessErrors, Wait, strings) {
value: scope.state.mem_capacity
}];
scope.min_capacity = _.min(adjustment_values, 'value');
scope.max_capacity = _.max(adjustment_values, 'value');
scope.min_capacity = _.minBy(adjustment_values, 'value');
scope.max_capacity = _.maxBy(adjustment_values, 'value');
capacityAdjusterController.init();
},
@@ -72,4 +72,4 @@ CapacityAdjuster.$inject = [
'InstanceGroupsStrings'
];
export default CapacityAdjuster;
export default CapacityAdjuster;

View File

@@ -22,7 +22,7 @@ export default ['Rest', 'Wait', 'NotificationsFormObject',
init();
function init() {
Rest.setUrl(GetBasePath('projects'));
Rest.setUrl(GetBasePath('notification_templates'));
Rest.options()
.then(({data}) => {
if (!data.actions.POST) {
@@ -205,7 +205,7 @@ export default ['Rest', 'Wait', 'NotificationsFormObject',
return $scope[i];
}
params.notification_configuration = _.object(Object.keys(form.fields)
params.notification_configuration = _.fromPairs(Object.keys(form.fields)
.filter(i => (form.fields[i].ngShow && form.fields[i].ngShow.indexOf(v) > -1))
.map(i => [i, processValue($scope[i], i, form.fields[i])]));

View File

@@ -275,7 +275,7 @@ export default ['Rest', 'Wait',
return $scope[i];
}
params.notification_configuration = _.object(Object.keys(form.fields)
params.notification_configuration = _.fromPairs(Object.keys(form.fields)
.filter(i => (form.fields[i].ngShow && form.fields[i].ngShow.indexOf(v) > -1))
.map(i => [i, processValue($scope[i], i, form.fields[i])]));

View File

@@ -20,7 +20,7 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
hover: false,
emptyListText: i18n.sprintf(i18n._("This list is populated by notification templates added from the %sNotifications%s section"), "&nbsp;<a ui-sref='notifications.add'>", "</a>&nbsp;"),
basePath: 'notification_templates',
ngIf: 'current_user.is_superuser || isOrgAdmin',
ngIf: 'current_user.is_superuser || isOrgAdmin || isNotificationAdmin',
fields: {
name: {
key: true,
@@ -40,6 +40,7 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
flag: 'notification_templates_success',
type: "toggle",
ngClick: "toggleNotification($event, notification.id, \"notification_templates_success\")",
ngDisabled: "!(current_user.is_superuser || isOrgAdmin)",
awToolTip: "{{ schedule.play_tip }}",
dataTipWatch: "schedule.play_tip",
dataPlacement: "right",
@@ -51,6 +52,7 @@ export default ['i18n', 'templateUrl', function(i18n, templateUrl){
flag: 'notification_templates_error',
type: "toggle",
ngClick: "toggleNotification($event, notification.id, \"notification_templates_error\")",
ngDisabled: "!(current_user.is_superuser || isOrgAdmin)",
awToolTip: "{{ schedule.play_tip }}",
dataTipWatch: "schedule.play_tip",
dataPlacement: "right",

View File

@@ -5,10 +5,10 @@
*************************************************/
export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup',
'OrganizationForm', 'Rest', 'ProcessErrors', 'Prompt',
'OrganizationForm', 'Rest', 'ProcessErrors', 'Prompt', '$rootScope',
'GetBasePath', 'Wait', '$state', 'ToggleNotification', 'CreateSelect2', 'InstanceGroupsService', 'InstanceGroupsData', 'ConfigData',
function($scope, $location, $stateParams, OrgAdminLookup,
OrganizationForm, Rest, ProcessErrors, Prompt,
OrganizationForm, Rest, ProcessErrors, Prompt, $rootScope,
GetBasePath, Wait, $state, ToggleNotification, CreateSelect2, InstanceGroupsService, InstanceGroupsData, ConfigData) {
let form = OrganizationForm(),
@@ -26,6 +26,12 @@ export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup',
$scope.isOrgAdmin = isOrgAdmin;
});
Rest.setUrl(GetBasePath('users') + $rootScope.current_user.id + '/roles/?role_field=notification_admin_role');
Rest.get()
.then(({data}) => {
$scope.isNotificationAdmin = (data.count && data.count > 0);
});
$scope.$watch('organization_obj.summary_fields.user_capabilities.edit', function(val) {
if (val === false) {
$scope.canAdd = false;

View File

@@ -133,7 +133,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
}
switch ($scope.scm_type.value) {
case 'git':
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' +
i18n._('Example URLs for GIT SCM include:') +
'</p><ul class=\"no-bullets\"><li>https://github.com/ansible/ansible.git</li>' +
@@ -146,7 +146,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
$scope.scmBranchLabel = i18n._('SCM Branch/Tag/Commit');
break;
case 'svn':
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + i18n._('Example URLs for Subversion SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://github.com/ansible/ansible</li><li>svn://servername.example.com/path</li>' +
'<li>svn+ssh://servername.example.com/path</li></ul>';
@@ -155,7 +155,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
$scope.scmBranchLabel = i18n._('Revision #');
break;
case 'hg':
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + i18n._('Example URLs for Mercurial SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://bitbucket.org/username/project</li><li>ssh://hg@bitbucket.org/username/project</li>' +
'<li>ssh://server.example.com/path</li></ul>' +
@@ -174,7 +174,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
$scope.lookupType = 'insights_credential';
break;
default:
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p> ' + i18n._('URL popover text') + '</p>';
$scope.credRequired = false;
$scope.lookupType = 'scm_credential';

View File

@@ -270,7 +270,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
}
switch ($scope.scm_type.value) {
case 'git':
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + i18n._('Example URLs for GIT SCM include:') + '</p><ul class=\"no-bullets\"><li>https://github.com/ansible/ansible.git</li>' +
'<li>git@github.com:ansible/ansible.git</li><li>git://servername.example.com/ansible.git</li></ul>' +
'<p>' + i18n.sprintf(i18n._('%sNote:%s When using SSH protocol for GitHub or Bitbucket, enter an SSH key only, ' +
@@ -281,7 +281,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
$scope.scmBranchLabel = i18n._('SCM Branch/Tag/Commit');
break;
case 'svn':
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + i18n._('Example URLs for Subversion SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://github.com/ansible/ansible</li><li>svn://servername.example.com/path</li>' +
'<li>svn+ssh://servername.example.com/path</li></ul>';
@@ -290,7 +290,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
$scope.scmBranchLabel = i18n._('Revision #');
break;
case 'hg':
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p>' + i18n._('Example URLs for Mercurial SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://bitbucket.org/username/project</li><li>ssh://hg@bitbucket.org/username/project</li>' +
'<li>ssh://server.example.com/path</li></ul>' +
@@ -309,7 +309,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'ProjectsForm', 'Rest',
$scope.lookupType = 'insights_credential';
break;
default:
$scope.credentialLabel = "SCM Credential";
$scope.credentialLabel = "SCM " + i18n._("Credential");
$scope.urlPopover = '<p> ' + i18n._('URL popover text');
$scope.credRequired = false;
$scope.lookupType = 'scm_credential';

View File

@@ -670,6 +670,8 @@ function(ConfigurationUtils, i18n, $rootScope) {
query += '&role_level=workflow_admin_role';
} else if ($state.current.name.includes('projects')) {
query += '&role_level=project_admin_role';
} else if ($state.current.name.includes('notifications')) {
query += '&role_level=notification_admin_role';
} else {
query += '&role_level=admin_role';
}

View File

@@ -36,10 +36,10 @@ export default ['$scope',
$scope.selection.selectedItems =
_items.filter(function(item) {
return item.isSelected;
}).pluck('value').value();
}).map('value').value();
$scope.selection.deselectedItems =
_items.pluck('value').difference($scope.selection.selectedItems)
_items.map('value').difference($scope.selection.selectedItems)
.value();
/**

View File

@@ -310,12 +310,12 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc
return [];
}
if(defaultParams) {
let stripped =_.pick(params, (value, key) => {
let stripped =_.pickBy(params, (value, key) => {
// setting the default value of a term to null in a state definition is a very explicit way to ensure it will NEVER generate a search tag, even with a non-default value
return defaultParams[key] !== value && key !== 'order_by' && key !== 'page' && key !== 'page_size' && defaultParams[key] !== null;
});
let strippedCopy = _.cloneDeep(stripped);
if(_.keys(_.pick(defaultParams, _.keys(strippedCopy))).length > 0){
if(_.keys(_.pickBy(defaultParams, _.keys(strippedCopy))).length > 0){
for (var key in strippedCopy) {
if (strippedCopy.hasOwnProperty(key)) {
let value = strippedCopy[key];
@@ -336,7 +336,7 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc
mergeQueryset (queryset, additional, singleSearchParam) {
const space = '%20and%20';
const merged = _.merge({}, queryset, additional, (objectValue, sourceValue, key, object) => {
const merged = _.mergeWith({}, queryset, additional, (objectValue, sourceValue, key, object) => {
if (!(object[key] && object[key] !== sourceValue)) {
// // https://lodash.com/docs/3.10.1#each
// If this returns undefined merging is handled by default _.merge algorithm
@@ -418,7 +418,7 @@ function QuerysetService ($q, Rest, ProcessErrors, $rootScope, Wait, DjangoSearc
termParams = searchWithoutKey(term, singleSearchParam);
}
params = _.merge(params, termParams, combineSameSearches);
params = _.mergeWith(params, termParams, combineSameSearches);
});
return params;

View File

@@ -102,7 +102,7 @@ function SmartSearchController (
const listName = $scope.list.name;
const baseRelatedTypePath = `models.${listName}.base.${rootField}.type`;
const isRelatedSearchTermField = (_.contains($scope.models[listName].related, rootField));
const isRelatedSearchTermField = (_.includes($scope.models[listName].related, rootField));
const isBaseModelRelatedSearchTermField = (_.get($scope, baseRelatedTypePath) === 'field');
return (isRelatedSearchTermField || isBaseModelRelatedSearchTermField);
@@ -254,7 +254,7 @@ function SmartSearchController (
defaults[key] = queryset[key];
}
});
const cleared = _(defaults).omit(_.isNull).value();
const cleared = _(defaults).omitBy(_.isNull).value();
delete cleared.page;
queryset = cleared;

View File

@@ -744,10 +744,20 @@ function($injector, $stateExtender, $log, i18n) {
// search will think they need to be set as search tags.
var params;
if(field.sourceModel === "organization"){
params = {
page_size: '5',
role_level: 'admin_role'
};
if (form.name === "notification_template") {
// Users with admin_role role level should also have
// notification_admin_role so this should handle regular admin
// users as well as notification admin users
params = {
page_size: '5',
role_level: 'notification_admin_role'
};
} else {
params = {
page_size: '5',
role_level: 'admin_role'
};
}
}
else if(field.sourceModel === "inventory_script"){
params = {

View File

@@ -21,7 +21,9 @@ export default ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'Rest',
$scope.canEdit = me.get('summary_fields.user_capabilities.edit');
$scope.isOrgAdmin = me.get('related.admin_of_organizations.count') > 0;
$scope.team_id = id;
setScopeFields(data);
_.forEach(form.fields, (value, key) => {
$scope[key] = data[key];
});
$scope.organization_name = data.summary_fields.organization.name;
OrgAdminLookup.checkForAdminAccess({organization: data.organization})
@@ -36,19 +38,6 @@ export default ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'Rest',
});
}
// @issue I think all this really want to do is _.forEach(form.fields, (field) =>{ $scope[field] = data[field]})
function setScopeFields(data) {
_(data)
.pick(function(value, key) {
return form.fields.hasOwnProperty(key) === true;
})
.forEach(function(value, key) {
$scope[key] = value;
})
.value();
return;
}
// prepares a data payload for a PUT request to the API
function processNewData(fields) {
var data = {};

View File

@@ -6,8 +6,8 @@
</div>
<div class="Prompt-previewTags--outer">
<div ng-show="promptData.launchConf.defaults.inventory.id && !promptData.prompts.inventory.value.id" class="Prompt-noSelectedItem">{{:: vm.strings.get('prompt.NO_INVENTORY_SELECTED') }}</div>
<at-tag tag="promptData.prompts.inventory.value.name" remove-tag="vm.deleteSelectedInventory()" ng-show="!readOnlyPrompts"></at-tag>
<at-tag tag="promptData.prompts.inventory.value.name" ng-show="readOnlyPrompts"></at-tag>
<at-tag tag="promptData.prompts.inventory.value.name" remove-tag="vm.deleteSelectedInventory()" ng-show="!readOnlyPrompts && promptData.prompts.inventory.value.id"></at-tag>
<at-tag tag="promptData.prompts.inventory.value.name" ng-show="readOnlyPrompts && promptData.prompts.inventory.value.id"></at-tag>
</div>
<div class="Prompt-previewTagRevert">
<a class="Prompt-revertLink" href="" ng-hide="readOnlyPrompts || promptData.prompts.inventory.value.id === promptData.launchConf.defaults.inventory.id" ng-click="vm.revert()">{{:: vm.strings.get('prompt.REVERT') }}</a>

View File

@@ -261,7 +261,7 @@ export default
scope.maxTextError = false;
if(scope.type.type==="text"){
if(scope.default.trim() !== ""){
if(scope.default && scope.default.trim() !== ""){
if(scope.default.trim().length < scope.text_min && scope.text_min !== "" ){
scope.minTextError = true;
}
@@ -272,7 +272,7 @@ export default
}
if(scope.type.type==="textarea"){
if(scope.default_textarea.trim() !== ""){
if(scope.default_textarea && scope.default_textarea.trim() !== ""){
if(scope.default_textarea.trim().length < scope.textarea_min && scope.textarea_min !== "" ){
scope.minTextError = true;
}
@@ -283,7 +283,7 @@ export default
}
if(scope.type.type==="password"){
if(scope.default_password.trim() !== ""){
if(scope.default_password && scope.default_password.trim() !== ""){
if(scope.default_password.trim().length < scope.password_min && scope.password_min !== "" ){
scope.minTextError = true;
}
@@ -293,7 +293,7 @@ export default
}
}
if(scope.type.type==="multiselect" && scope.default_multiselect.trim() !== ""){
if(scope.type.type==="multiselect" && scope.default_multiselect && scope.default_multiselect.trim() !== ""){
choiceArray = scope.choices.split(/\n/);
answerArray = scope.default_multiselect.split(/\n/);
@@ -306,7 +306,7 @@ export default
}
}
if(scope.type.type==="multiplechoice" && scope.default.trim() !== ""){
if(scope.type.type==="multiplechoice" && scope.default && scope.default.trim() !== ""){
choiceArray = scope.choices.split(/\n/);
if($.inArray(scope.default, choiceArray)===-1){
scope.invalidChoice = true;

View File

@@ -7,9 +7,10 @@
export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
'$state', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel',
'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout',
'i18n',
function($scope, WorkflowService, GetBasePath, TemplatesService,
$state, ProcessErrors, CreateSelect2, $q, JobTemplate,
Empty, PromptService, Rest, TemplatesStrings, $timeout) {
Empty, PromptService, Rest, TemplatesStrings, $timeout, i18n) {
let promptWatcher, surveyQuestionWatcher, credentialsWatcher;
@@ -301,15 +302,15 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
if (!optionsToInclude) {
$scope.edgeTypeOptions = [
{
label: 'Always',
label: i18n._('Always'),
value: 'always'
},
{
label: 'On Success',
label: i18n._('On Success'),
value: 'success'
},
{
label: 'On Failure',
label: i18n._('On Failure'),
value: 'failure'
}
];
@@ -641,6 +642,31 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
if (!_.isEmpty($scope.nodeBeingEdited.promptData)) {
$scope.promptData = _.cloneDeep($scope.nodeBeingEdited.promptData);
const launchConf = $scope.promptData.launchConf;
if (!launchConf.survey_enabled &&
!launchConf.ask_inventory_on_launch &&
!launchConf.ask_credential_on_launch &&
!launchConf.ask_verbosity_on_launch &&
!launchConf.ask_job_type_on_launch &&
!launchConf.ask_limit_on_launch &&
!launchConf.ask_tags_on_launch &&
!launchConf.ask_skip_tags_on_launch &&
!launchConf.ask_diff_mode_on_launch &&
!launchConf.credential_needed_to_start &&
!launchConf.ask_variables_on_launch &&
launchConf.variables_needed_to_start.length === 0) {
$scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false;
} else {
$scope.showPromptButton = true;
if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has($scope, 'nodeBeingEdited.originalNodeObj.summary_fields.inventory')) {
$scope.promptModalMissingReqFields = true;
} else {
$scope.promptModalMissingReqFields = false;
}
}
} else if (
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job_template' ||
_.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'job_template'
@@ -727,8 +753,8 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
!launchConf.ask_tags_on_launch &&
!launchConf.ask_skip_tags_on_launch &&
!launchConf.ask_diff_mode_on_launch &&
!launchConf.survey_enabled &&
!launchConf.credential_needed_to_start &&
!launchConf.ask_variables_on_launch &&
launchConf.variables_needed_to_start.length === 0) {
$scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false;
@@ -839,19 +865,19 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
switch($scope.nodeBeingEdited.edgeType) {
case "always":
$scope.edgeType = {label: "Always", value: "always"};
$scope.edgeType = {label: i18n._("Always"), value: "always"};
if (siblingConnectionTypes.length === 1 && _.includes(siblingConnectionTypes, "always") || $scope.nodeBeingEdited.isRoot) {
edgeDropdownOptions = ["always"];
}
break;
case "success":
$scope.edgeType = {label: "On Success", value: "success"};
$scope.edgeType = {label: i18n._("On Success"), value: "success"};
if (siblingConnectionTypes.length !== 0 && (!_.includes(siblingConnectionTypes, "always"))) {
edgeDropdownOptions = ["success", "failure"];
}
break;
case "failure":
$scope.edgeType = {label: "On Failure", value: "failure"};
$scope.edgeType = {label: i18n._("On Failure"), value: "failure"};
if (siblingConnectionTypes.length !== 0 && (!_.includes(siblingConnectionTypes, "always"))) {
edgeDropdownOptions = ["success", "failure"];
}
@@ -978,7 +1004,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
switch($scope.nodeBeingEdited.edgeType) {
case "always":
$scope.edgeType = {label: "Always", value: "always"};
$scope.edgeType = {label: i18n._("Always"), value: "always"};
if (
_.includes(siblingConnectionTypes, "always") &&
!_.includes(siblingConnectionTypes, "success") &&
@@ -990,7 +1016,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
}
break;
case "success":
$scope.edgeType = {label: "On Success", value: "success"};
$scope.edgeType = {label: i18n._("On Success"), value: "success"};
if (
(_.includes(siblingConnectionTypes, "success") || _.includes(siblingConnectionTypes, "failure")) &&
!_.includes(siblingConnectionTypes, "always")
@@ -1001,7 +1027,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
}
break;
case "failure":
$scope.edgeType = {label: "On Failure", value: "failure"};
$scope.edgeType = {label: i18n._("On Failure"), value: "failure"};
if (
(_.includes(siblingConnectionTypes, "success") || _.includes(siblingConnectionTypes, "failure")) &&
!_.includes(siblingConnectionTypes, "always")
@@ -1071,8 +1097,8 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService',
!launchConf.ask_tags_on_launch &&
!launchConf.ask_skip_tags_on_launch &&
!launchConf.ask_diff_mode_on_launch &&
!launchConf.survey_enabled &&
!launchConf.credential_needed_to_start &&
!launchConf.ask_variables_on_launch &&
launchConf.variables_needed_to_start.length === 0) {
$scope.showPromptButton = false;
$scope.promptModalMissingReqFields = false;

View File

@@ -17,7 +17,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
'$state', 'i18n', 'resolvedModels', 'resourceData',
function($scope, $rootScope, $stateParams, UserForm, Rest, ProcessErrors,
GetBasePath, Wait, CreateSelect2, $state, i18n, models, resourceData) {
for (var i = 0; i < user_type_options.length; i++) {
user_type_options[i].label = i18n._(user_type_options[i].label);
}
@@ -28,12 +28,16 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
id = $stateParams.user_id,
defaultUrl = GetBasePath('users') + id,
user_obj = resourceData.data;
$scope.breadcrumb.user_name = user_obj.username;
init();
function init() {
_.forEach(form.fields, (value, key) => {
$scope[key] = user_obj[key];
});
$scope.canEdit = me.get('summary_fields.user_capabilities.edit');
$scope.isOrgAdmin = me.get('related.admin_of_organizations.count') > 0;
$scope.isCurrentlyLoggedInUser = (parseInt(id) === $rootScope.current_user.id);
@@ -73,9 +77,6 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
$scope.$watch('user_obj.summary_fields.user_capabilities.edit', function(val) {
$scope.canAdd = (val === false) ? false : true;
});
setScopeFields(user_obj);
}
function user_type_sync($scope) {
@@ -107,19 +108,6 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
};
}
function setScopeFields(data) {
_(data)
.pick(function(value, key) {
return form.fields.hasOwnProperty(key) === true;
})
.forEach(function(value, key) {
$scope[key] = value;
})
.value();
return;
}
$scope.redirectToResource = function(resource) {
let type = resource.summary_fields.resource_type.replace(/ /g , "_");
var id = resource.related[type].split("/")[4];
@@ -152,7 +140,11 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest',
var processNewData = function(fields) {
var data = {};
_.forEach(fields, function(value, key) {
if ($scope[key] !== '' && $scope[key] !== null && $scope[key] !== undefined) {
if (value.type === 'sensitive') {
if ($scope[key] !== '' && $scope[key] !== null && $scope[key] !== undefined) {
data[key] = $scope[key];
}
} else {
data[key] = $scope[key];
}
});

View File

@@ -223,6 +223,11 @@
}
}
},
"lodash": {
"version": "3.8.0",
"from": "lodash@>=3.8.0 <3.9.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.8.0.tgz"
},
"rrule": {
"version": "2.2.0-dev",
"from": "jkbrzt/rrule#4ff63b2f8524fd6d5ba6e80db770953b5cd08a0c",
@@ -233,7 +238,7 @@
"angular-tz-extensions": {
"version": "0.5.2",
"from": "git+https://git@github.com/ansible/angular-tz-extensions.git#v0.5.2",
"resolved": "git+https://git@github.com/ansible/angular-tz-extensions.git#9cabb05d58079092bfb29ccae721b35b46f28af6",
"resolved": "git://github.com/ansible/angular-tz-extensions.git#9cabb05d58079092bfb29ccae721b35b46f28af6",
"dependencies": {
"jquery": {
"version": "3.3.1",
@@ -1496,7 +1501,15 @@
"version": "0.19.0",
"from": "cheerio@>=0.19.0 <0.20.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-0.19.0.tgz",
"dev": true
"dev": true,
"dependencies": {
"lodash": {
"version": "3.10.1",
"from": "lodash@>=3.2.0 <4.0.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
"dev": true
}
}
},
"chokidar": {
"version": "1.7.0",
@@ -5420,6 +5433,12 @@
"resolved": "https://registry.npmjs.org/karma/-/karma-1.7.1.tgz",
"dev": true,
"dependencies": {
"lodash": {
"version": "3.10.1",
"from": "lodash@>=3.8.0 <4.0.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
"dev": true
},
"source-map": {
"version": "0.5.7",
"from": "source-map@>=0.5.3 <0.6.0",
@@ -5723,6 +5742,12 @@
"from": "inquirer@>=0.8.2 <0.9.0",
"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.8.5.tgz",
"dev": true
},
"lodash": {
"version": "3.10.1",
"from": "lodash@>=3.6.0 <4.0.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
"dev": true
}
}
},
@@ -5796,9 +5821,9 @@
"dev": true
},
"lodash": {
"version": "3.8.0",
"from": "lodash@>=3.8.0 <3.9.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.8.0.tgz"
"version": "4.17.10",
"from": "lodash@>=4.17.10 <4.18.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz"
},
"lodash._arraycopy": {
"version": "3.0.0",
@@ -6317,6 +6342,12 @@
"from": "glob@>=5.0.0 <6.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz",
"dev": true
},
"lodash": {
"version": "3.10.1",
"from": "lodash@>=3.0.0 <4.0.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz",
"dev": true
}
}
},

View File

@@ -29,7 +29,8 @@
"lint": "eslint .",
"dev": "webpack --config build/webpack.development.js --progress",
"watch": "webpack-dev-server --config build/webpack.watch.js --progress --https",
"production": "webpack --config build/webpack.production.js"
"production": "webpack --config build/webpack.production.js",
"grab-licenses": "./utils/get_licenses.js"
},
"devDependencies": {
"angular-mocks": "~1.6.6",
@@ -120,7 +121,7 @@
"jquery-ui": "^1.12.1",
"js-yaml": "^3.2.7",
"legacy-loader": "0.0.2",
"lodash": "~3.8.0",
"lodash": "~4.17.10",
"lr-infinite-scroll": "git+https://git@github.com/lorenzofox3/lrInfiniteScroll",
"moment": "^2.19.4",
"ng-toast": "git+https://git@github.com/ansible/ngToast#v2.1.1",

View File

@@ -236,8 +236,8 @@ msgstr ""
msgid "Add Project"
msgstr ""
#: client/src/shared/form-generator.js:1718
#: client/src/templates/job_templates/job-template.form.js:464
#: client/src/shared/form-generator.js:1731
#: client/src/templates/job_templates/job-template.form.js:468
#: client/src/templates/workflows.form.js:205
msgid "Add Survey"
msgstr ""
@@ -277,12 +277,12 @@ msgstr ""
#: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:115
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:117
#: client/src/projects/projects.form.js:255
#: client/src/templates/job_templates/job-template.form.js:407
#: client/src/templates/job_templates/job-template.form.js:411
#: client/src/templates/workflows.form.js:148
msgid "Add a permission"
msgstr ""
#: client/src/shared/form-generator.js:1453
#: client/src/shared/form-generator.js:1466
msgid "Admin"
msgstr ""
@@ -315,8 +315,8 @@ msgstr ""
msgid "All Jobs"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:286
#: client/src/templates/job_templates/job-template.form.js:293
#: client/src/templates/job_templates/job-template.form.js:290
#: client/src/templates/job_templates/job-template.form.js:297
msgid "Allow Provisioning Callbacks"
msgstr ""
@@ -337,8 +337,8 @@ msgstr ""
#: client/src/organizations/organizations.form.js:52
#: client/src/projects/projects.form.js:207
#: client/src/projects/projects.form.js:212
#: client/src/templates/job_templates/job-template.form.js:235
#: client/src/templates/job_templates/job-template.form.js:241
#: client/src/templates/job_templates/job-template.form.js:239
#: client/src/templates/job_templates/job-template.form.js:245
msgid "Ansible Environment"
msgstr ""
@@ -434,7 +434,7 @@ msgstr ""
msgid "Associate this host with a new group"
msgstr ""
#: client/src/shared/form-generator.js:1455
#: client/src/shared/form-generator.js:1468
msgid "Auditor"
msgstr ""
@@ -703,7 +703,7 @@ msgstr ""
#: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:188
#: client/src/configuration/configuration.controller.js:617
#: client/src/scheduler/scheduler.strings.js:56
#: client/src/shared/form-generator.js:1706
#: client/src/shared/form-generator.js:1719
#: client/src/shared/lookup/lookup-modal.partial.html:19
#: client/src/workflow-results/workflow-results.controller.js:38
msgid "Cancel"
@@ -759,7 +759,7 @@ msgstr ""
msgid "Check"
msgstr ""
#: client/src/shared/form-generator.js:1078
#: client/src/shared/form-generator.js:1087
msgid "Choose a %s"
msgstr ""
@@ -844,7 +844,7 @@ msgid "Client Secret"
msgstr ""
#: client/src/scheduler/scheduler.strings.js:55
#: client/src/shared/form-generator.js:1710
#: client/src/shared/form-generator.js:1723
msgid "Close"
msgstr ""
@@ -870,7 +870,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/related/hosts/related-host.form.js:128
#: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:155
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:172
#: client/src/templates/job_templates/job-template.form.js:439
#: client/src/templates/job_templates/job-template.form.js:443
#: client/src/templates/workflows.form.js:180
msgid "Completed Jobs"
msgstr ""
@@ -1338,7 +1338,7 @@ msgstr ""
#: client/features/output/output.strings.js:34
#: client/features/users/tokens/tokens.strings.js:14
#: client/src/license/license.partial.html:5
#: client/src/shared/form-generator.js:1488
#: client/src/shared/form-generator.js:1501
msgid "Details"
msgstr ""
@@ -1514,8 +1514,8 @@ msgstr ""
msgid "Edit Question"
msgstr ""
#: client/src/shared/form-generator.js:1722
#: client/src/templates/job_templates/job-template.form.js:471
#: client/src/shared/form-generator.js:1735
#: client/src/templates/job_templates/job-template.form.js:475
#: client/src/templates/workflows.form.js:212
msgid "Edit Survey"
msgstr ""
@@ -1608,8 +1608,8 @@ msgstr ""
msgid "Email"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:299
#: client/src/templates/job_templates/job-template.form.js:304
#: client/src/templates/job_templates/job-template.form.js:303
#: client/src/templates/job_templates/job-template.form.js:308
#: client/src/templates/workflows.form.js:100
#: client/src/templates/workflows.form.js:105
msgid "Enable Concurrent Jobs"
@@ -1620,8 +1620,8 @@ msgid "Enable External Logging"
msgstr ""
#: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:124
#: client/src/templates/job_templates/job-template.form.js:275
#: client/src/templates/job_templates/job-template.form.js:280
#: client/src/templates/job_templates/job-template.form.js:279
#: client/src/templates/job_templates/job-template.form.js:284
msgid "Enable Privilege Escalation"
msgstr ""
@@ -1629,7 +1629,7 @@ msgstr ""
msgid "Enable survey"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:290
#: client/src/templates/job_templates/job-template.form.js:294
msgid "Enables creation of a provisioning callback URL. Using the URL a host can contact {{BRAND_NAME}} and request a configuration update using this job template."
msgstr ""
@@ -1815,8 +1815,8 @@ msgstr ""
#: client/src/job-submission/job-submission.partial.html:165
#: client/src/partials/logviewer.html:8
#: client/src/scheduler/scheduler.strings.js:53
#: client/src/templates/job_templates/job-template.form.js:353
#: client/src/templates/job_templates/job-template.form.js:360
#: client/src/templates/job_templates/job-template.form.js:357
#: client/src/templates/job_templates/job-template.form.js:364
#: client/src/templates/workflows.form.js:83
#: client/src/templates/workflows.form.js:90
#: client/src/workflow-results/workflow-results.controller.js:122
@@ -2091,8 +2091,8 @@ msgstr ""
msgid "Host (Authentication URL)"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:335
#: client/src/templates/job_templates/job-template.form.js:344
#: client/src/templates/job_templates/job-template.form.js:339
#: client/src/templates/job_templates/job-template.form.js:348
msgid "Host Config Key"
msgstr ""
@@ -2269,7 +2269,7 @@ msgstr ""
msgid "If checked, any hosts and groups that were previously present on the external source but are now removed will be removed from the Tower inventory. Hosts and groups that were not managed by the inventory source will be promoted to the next manually created group or if there is no manually created group to promote them into, they will be left in the \"all\" default group for the inventory."
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:278
#: client/src/templates/job_templates/job-template.form.js:282
msgid "If enabled, run this playbook as an administrator."
msgstr ""
@@ -2277,11 +2277,11 @@ msgstr ""
msgid "If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansible&#x2019;s --diff mode."
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:263
#: client/src/templates/job_templates/job-template.form.js:267
msgid "If enabled, show the changes made by Ansible tasks, where supported. This is equivalent to Ansible&#x2019s --diff mode."
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:302
#: client/src/templates/job_templates/job-template.form.js:306
msgid "If enabled, simultaneous runs of this job template will be allowed."
msgstr ""
@@ -2289,7 +2289,7 @@ msgstr ""
msgid "If enabled, simultaneous runs of this workflow job template will be allowed."
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:313
#: client/src/templates/job_templates/job-template.form.js:317
msgid "If enabled, use cached facts if available and store discovered facts in the cache."
msgstr ""
@@ -2360,8 +2360,8 @@ msgstr ""
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:64
#: client/src/organizations/organizations.form.js:38
#: client/src/organizations/organizations.form.js:41
#: client/src/templates/job_templates/job-template.form.js:248
#: client/src/templates/job_templates/job-template.form.js:251
#: client/src/templates/job_templates/job-template.form.js:252
#: client/src/templates/job_templates/job-template.form.js:255
msgid "Instance Groups"
msgstr ""
@@ -2679,7 +2679,7 @@ msgstr ""
msgid "Last Updated"
msgstr ""
#: client/src/shared/form-generator.js:1714
#: client/src/shared/form-generator.js:1727
msgid "Launch"
msgstr ""
@@ -2773,7 +2773,7 @@ msgstr ""
msgid "Live events: error connecting to the server."
msgstr ""
#: client/src/shared/form-generator.js:1992
#: client/src/shared/form-generator.js:2005
msgid "Loading..."
msgstr ""
@@ -2843,6 +2843,10 @@ msgstr ""
msgid "Manual projects do not require an SCM update"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:234
msgid "Max 512 characters per label."
msgstr ""
#: client/src/login/loginModal/loginModal.partial.html:28
msgid "Maximum per-user sessions reached. Please sign in."
msgstr ""
@@ -3153,7 +3157,7 @@ msgid "No recent notifications."
msgstr ""
#: client/src/inventories-hosts/hosts/hosts.partial.html:36
#: client/src/shared/form-generator.js:1886
#: client/src/shared/form-generator.js:1899
#: client/src/shared/list-generator/list-generator.factory.js:240
msgid "No records matched your search."
msgstr ""
@@ -3300,7 +3304,7 @@ msgstr ""
#: client/src/notifications/notificationTemplates.form.js:453
#: client/src/partials/logviewer.html:7
#: client/src/templates/job_templates/job-template.form.js:271
#: client/src/templates/job_templates/job-template.form.js:275
#: client/src/templates/workflows.form.js:96
msgid "Options"
msgstr ""
@@ -3414,7 +3418,7 @@ msgid "PLEASE ADD A SURVEY PROMPT."
msgstr ""
#: client/src/organizations/list/organizations-list.partial.html:37
#: client/src/shared/form-generator.js:1892
#: client/src/shared/form-generator.js:1905
#: client/src/shared/list-generator/list-generator.factory.js:248
msgid "PLEASE ADD ITEMS TO THIS LIST"
msgstr ""
@@ -3448,7 +3452,7 @@ msgstr ""
msgid "Pagerduty subdomain"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:359
#: client/src/templates/job_templates/job-template.form.js:363
msgid "Pass extra command line variables to the playbook. Provide key/value pairs using either YAML or JSON. Refer to the Ansible Tower documentation for example syntax."
msgstr ""
@@ -3531,7 +3535,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:106
#: client/src/projects/projects.form.js:247
#: client/src/teams/teams.form.js:120
#: client/src/templates/job_templates/job-template.form.js:398
#: client/src/templates/job_templates/job-template.form.js:402
#: client/src/templates/workflows.form.js:139
#: client/src/users/users.form.js:189
msgid "Permissions"
@@ -3542,7 +3546,7 @@ msgid "Personal Access Token"
msgstr ""
#: client/features/output/output.strings.js:63
#: client/src/shared/form-generator.js:1076
#: client/src/shared/form-generator.js:1085
#: client/src/templates/job_templates/job-template.form.js:107
#: client/src/templates/job_templates/job-template.form.js:115
msgid "Playbook"
@@ -3598,15 +3602,15 @@ msgstr ""
msgid "Please enter a URL that begins with ssh, http or https. The URL may not contain the '@' character."
msgstr ""
#: client/src/shared/form-generator.js:1165
#: client/src/shared/form-generator.js:1178
msgid "Please enter a number greater than %d and less than %d."
msgstr ""
#: client/src/shared/form-generator.js:1167
#: client/src/shared/form-generator.js:1180
msgid "Please enter a number greater than %d."
msgstr ""
#: client/src/shared/form-generator.js:1159
#: client/src/shared/form-generator.js:1172
msgid "Please enter a number."
msgstr ""
@@ -3708,7 +3712,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:102
#: client/src/projects/projects.form.js:239
#: client/src/teams/teams.form.js:116
#: client/src/templates/job_templates/job-template.form.js:391
#: client/src/templates/job_templates/job-template.form.js:395
#: client/src/templates/workflows.form.js:132
msgid "Please save before assigning permissions."
msgstr ""
@@ -3772,11 +3776,11 @@ msgstr ""
msgid "Please select Users from the list below."
msgstr ""
#: client/src/shared/form-generator.js:1200
#: client/src/shared/form-generator.js:1213
msgid "Please select a number between"
msgstr ""
#: client/src/shared/form-generator.js:1196
#: client/src/shared/form-generator.js:1209
msgid "Please select a number."
msgstr ""
@@ -3784,10 +3788,10 @@ msgstr ""
msgid "Please select a value"
msgstr ""
#: client/src/shared/form-generator.js:1088
#: client/src/shared/form-generator.js:1156
#: client/src/shared/form-generator.js:1277
#: client/src/shared/form-generator.js:1385
#: client/src/shared/form-generator.js:1097
#: client/src/shared/form-generator.js:1169
#: client/src/shared/form-generator.js:1290
#: client/src/shared/form-generator.js:1398
msgid "Please select a value."
msgstr ""
@@ -3799,7 +3803,7 @@ msgstr ""
msgid "Please select an organization before editing the host filter."
msgstr ""
#: client/src/shared/form-generator.js:1193
#: client/src/shared/form-generator.js:1206
msgid "Please select at least one value."
msgstr ""
@@ -3942,8 +3946,8 @@ msgstr ""
#: client/src/templates/job_templates/job-template.form.js:185
#: client/src/templates/job_templates/job-template.form.js:202
#: client/src/templates/job_templates/job-template.form.js:219
#: client/src/templates/job_templates/job-template.form.js:266
#: client/src/templates/job_templates/job-template.form.js:366
#: client/src/templates/job_templates/job-template.form.js:270
#: client/src/templates/job_templates/job-template.form.js:370
#: client/src/templates/job_templates/job-template.form.js:60
#: client/src/templates/job_templates/job-template.form.js:86
msgid "Prompt on launch"
@@ -3982,8 +3986,8 @@ msgstr ""
msgid "Provide the named URL encoded name or id of the remote Tower inventory to be imported."
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:322
#: client/src/templates/job_templates/job-template.form.js:330
#: client/src/templates/job_templates/job-template.form.js:326
#: client/src/templates/job_templates/job-template.form.js:334
msgid "Provisioning Callback URL"
msgstr ""
@@ -4492,7 +4496,7 @@ msgstr ""
#: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:193
#: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:158
#: client/src/scheduler/scheduler.strings.js:57
#: client/src/shared/form-generator.js:1698
#: client/src/shared/form-generator.js:1711
msgid "Save"
msgstr ""
@@ -4543,7 +4547,7 @@ msgstr ""
#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:35
#: client/src/inventories-hosts/inventories/related/sources/sources.form.js:440
#: client/src/projects/projects.form.js:290
#: client/src/templates/job_templates/job-template.form.js:444
#: client/src/templates/job_templates/job-template.form.js:448
#: client/src/templates/workflows.form.js:185
msgid "Schedules"
msgstr ""
@@ -4566,7 +4570,7 @@ msgstr ""
msgid "Security Token Service (STS) is a web service that enables you to request temporary, limited-privilege credentials for AWS Identity and Access Management (IAM) users."
msgstr ""
#: client/src/shared/form-generator.js:1702
#: client/src/shared/form-generator.js:1715
#: client/src/shared/lookup/lookup-modal.directive.js:59
#: client/src/shared/lookup/lookup-modal.partial.html:20
msgid "Select"
@@ -4634,7 +4638,7 @@ msgstr ""
msgid "Select the Instance Groups for this Inventory to run on. Refer to the Ansible Tower documentation for more detail."
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:250
#: client/src/templates/job_templates/job-template.form.js:254
msgid "Select the Instance Groups for this Job Template to run on."
msgstr ""
@@ -4642,7 +4646,7 @@ msgstr ""
msgid "Select the Instance Groups for this Organization to run on."
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:240
#: client/src/templates/job_templates/job-template.form.js:244
msgid "Select the custom Python virtual environment for this job template to run on."
msgstr ""
@@ -4709,8 +4713,8 @@ msgstr ""
#: client/features/templates/templates.strings.js:46
#: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:115
#: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:118
#: client/src/templates/job_templates/job-template.form.js:257
#: client/src/templates/job_templates/job-template.form.js:260
#: client/src/templates/job_templates/job-template.form.js:261
#: client/src/templates/job_templates/job-template.form.js:264
msgid "Show Changes"
msgstr ""
@@ -4757,7 +4761,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/inventory.list.js:86
#: client/src/inventories-hosts/inventories/list/inventory-list.controller.js:76
#: client/src/organizations/linkout/controllers/organizations-inventories.controller.js:70
#: client/src/shared/form-generator.js:1463
#: client/src/shared/form-generator.js:1476
msgid "Smart Inventory"
msgstr ""
@@ -5111,8 +5115,8 @@ msgstr ""
msgid "Textarea"
msgstr ""
#: client/src/shared/form-generator.js:1393
#: client/src/shared/form-generator.js:1399
#: client/src/shared/form-generator.js:1406
#: client/src/shared/form-generator.js:1412
msgid "That value was not found. Please enter or select a valid value."
msgstr ""
@@ -5564,12 +5568,12 @@ msgstr ""
#: client/src/organizations/organizations.form.js:48
#: client/src/projects/projects.form.js:209
#: client/src/templates/job_templates/job-template.form.js:237
#: client/src/templates/job_templates/job-template.form.js:241
msgid "Use Default Environment"
msgstr ""
#: client/src/templates/job_templates/job-template.form.js:310
#: client/src/templates/job_templates/job-template.form.js:315
#: client/src/templates/job_templates/job-template.form.js:314
#: client/src/templates/job_templates/job-template.form.js:319
msgid "Use Fact Cache"
msgstr ""
@@ -5764,8 +5768,8 @@ msgstr ""
msgid "View Project checkout results"
msgstr ""
#: client/src/shared/form-generator.js:1726
#: client/src/templates/job_templates/job-template.form.js:455
#: client/src/shared/form-generator.js:1739
#: client/src/templates/job_templates/job-template.form.js:459
#: client/src/templates/workflows.form.js:196
msgid "View Survey"
msgstr ""
@@ -5946,7 +5950,7 @@ msgstr ""
msgid "Workflow Templates"
msgstr ""
#: client/src/shared/form-generator.js:1730
#: client/src/shared/form-generator.js:1743
#: client/src/templates/workflows.form.js:222
msgid "Workflow Visualizer"
msgstr ""
@@ -6032,7 +6036,7 @@ msgstr ""
#: client/src/inventories-hosts/inventories/related/groups/list/groups-list.partial.html:24
#: client/src/job-submission/job-submission.partial.html:317
#: client/src/shared/form-generator.js:1200
#: client/src/shared/form-generator.js:1213
#: client/src/templates/prompt/steps/survey/prompt-survey.partial.html:42
msgid "and"
msgstr ""
@@ -6144,7 +6148,7 @@ msgstr ""
msgid "organization"
msgstr ""
#: client/src/shared/form-generator.js:1076
#: client/src/shared/form-generator.js:1085
msgid "playbook"
msgstr ""

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