foreman_openbolt 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +190 -19
- data/Rakefile +17 -93
- data/app/controllers/foreman_openbolt/task_controller.rb +61 -49
- data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +11 -10
- data/app/lib/actions/foreman_openbolt/poll_task_status.rb +70 -60
- data/app/models/foreman_openbolt/task_job.rb +16 -17
- data/config/routes.rb +0 -1
- data/lib/foreman_openbolt/engine.rb +11 -11
- data/lib/foreman_openbolt/version.rb +1 -1
- data/lib/proxy_api/openbolt.rb +25 -9
- data/lib/tasks/foreman_openbolt_tasks.rake +1 -22
- data/locale/gemspec.rb +1 -1
- data/package.json +11 -15
- data/test/acceptance/acceptance_helper.rb +146 -0
- data/test/acceptance/docker/docker-compose.yml +69 -0
- data/test/acceptance/docker/foreman/Dockerfile +45 -0
- data/test/acceptance/docker/foreman/entrypoint.sh +26 -0
- data/test/acceptance/docker/target/Dockerfile +29 -0
- data/test/acceptance/docker/target/entrypoint.sh +11 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/complex_params.json +30 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/complex_params.sh +16 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/echo.json +13 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/echo.sh +3 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/failing_task.json +8 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/failing_task.sh +3 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/noop_task.json +8 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/noop_task.sh +2 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/slow_task.json +14 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/slow_task.sh +3 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/target_conditional.json +13 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/target_conditional.sh +9 -0
- data/test/acceptance/fixtures/openbolt.yml +7 -0
- data/test/acceptance/tests/error_handling_test.rb +40 -0
- data/test/acceptance/tests/host_selector_test.rb +31 -0
- data/test/acceptance/tests/launch_task_test.rb +96 -0
- data/test/acceptance/tests/parameter_table_test.rb +61 -0
- data/test/acceptance/tests/settings_test.rb +95 -0
- data/test/acceptance/tests/ssh_options_test.rb +77 -0
- data/test/acceptance/tests/task_execution_test.rb +40 -0
- data/test/acceptance/tests/task_history_test.rb +84 -0
- data/test/acceptance/tests/transport_options_test.rb +121 -0
- data/test/test_plugin_helper.rb +12 -3
- data/test/unit/controllers/task_controller_test.rb +351 -0
- data/test/unit/docker/Dockerfile +47 -0
- data/test/unit/docker/docker-compose.yml +33 -0
- data/test/unit/docker/entrypoint.sh +4 -0
- data/test/unit/factories/foreman_openbolt_factories.rb +39 -0
- data/test/unit/lib/actions/cleanup_proxy_artifacts_test.rb +51 -0
- data/test/unit/lib/actions/poll_task_status_test.rb +141 -0
- data/test/unit/lib/proxy_api/openbolt_test.rb +174 -0
- data/test/unit/models/task_job_test.rb +278 -0
- data/webpack/__mocks__/foremanReact/common/I18n.js +15 -0
- data/webpack/__mocks__/foremanReact/components/ToastsList/index.js +6 -0
- data/webpack/__mocks__/foremanReact/redux/API/index.js +11 -0
- data/webpack/src/Components/LaunchTask/FieldTable.js +8 -5
- data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +74 -62
- data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +11 -13
- data/webpack/src/Components/LaunchTask/HostSelector/index.js +28 -33
- data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +3 -2
- data/webpack/src/Components/LaunchTask/ParameterField.js +2 -0
- data/webpack/src/Components/LaunchTask/SmartProxySelect.js +2 -1
- data/webpack/src/Components/LaunchTask/TaskSelect.js +3 -3
- data/webpack/src/Components/LaunchTask/__tests__/EmptyContent.test.js +10 -0
- data/webpack/src/Components/LaunchTask/__tests__/LaunchTask.test.js +83 -0
- data/webpack/src/Components/LaunchTask/__tests__/ParameterField.test.js +86 -0
- data/webpack/src/Components/LaunchTask/__tests__/ParametersSection.test.js +50 -0
- data/webpack/src/Components/LaunchTask/__tests__/SmartProxySelect.test.js +63 -0
- data/webpack/src/Components/LaunchTask/__tests__/TaskSelect.test.js +39 -0
- data/webpack/src/Components/LaunchTask/hooks/__tests__/useOpenBoltOptions.test.js +90 -0
- data/webpack/src/Components/LaunchTask/hooks/__tests__/useSmartProxies.test.js +69 -0
- data/webpack/src/Components/LaunchTask/hooks/__tests__/useTasksData.test.js +103 -0
- data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +9 -11
- data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +12 -13
- data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +6 -13
- data/webpack/src/Components/LaunchTask/index.js +9 -27
- data/webpack/src/Components/TaskExecution/ExecutionDetails.js +29 -29
- data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +9 -10
- data/webpack/src/Components/TaskExecution/LoadingIndicator.js +7 -2
- data/webpack/src/Components/TaskExecution/ResultDisplay.js +13 -17
- data/webpack/src/Components/TaskExecution/TaskDetails.js +58 -67
- data/webpack/src/Components/TaskExecution/__tests__/ExecutionDetails.test.js +47 -0
- data/webpack/src/Components/TaskExecution/__tests__/ExecutionDisplay.test.js +29 -0
- data/webpack/src/Components/TaskExecution/__tests__/LoadingIndicator.test.js +25 -0
- data/webpack/src/Components/TaskExecution/__tests__/ResultDisplay.test.js +28 -0
- data/webpack/src/Components/TaskExecution/__tests__/TaskDetails.test.js +38 -0
- data/webpack/src/Components/TaskExecution/__tests__/TaskExecution.test.js +80 -0
- data/webpack/src/Components/TaskExecution/hooks/__tests__/useJobPolling.test.js +177 -0
- data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +34 -33
- data/webpack/src/Components/TaskExecution/index.js +10 -12
- data/webpack/src/Components/TaskHistory/TaskPopover.js +9 -12
- data/webpack/src/Components/TaskHistory/__tests__/TaskHistory.test.js +109 -0
- data/webpack/src/Components/TaskHistory/__tests__/TaskPopover.test.js +26 -0
- data/webpack/src/Components/TaskHistory/index.js +21 -29
- data/webpack/src/Components/common/HostsPopover.js +12 -3
- data/webpack/src/Components/common/__tests__/HostsPopover.test.js +20 -0
- data/webpack/src/Components/common/__tests__/helpers.test.js +135 -0
- data/webpack/src/Components/common/helpers.js +34 -5
- data/webpack/test_setup.js +34 -11
- metadata +65 -87
- data/test/factories/foreman_openbolt_factories.rb +0 -7
- data/test/unit/foreman_openbolt_test.rb +0 -13
- data/webpack/global_test_setup.js +0 -11
- data/webpack/webpack.config.js +0 -7
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
2
|
import PropTypes from 'prop-types';
|
|
3
3
|
import { useQuery } from '@apollo/client';
|
|
4
4
|
import {
|
|
@@ -25,69 +25,78 @@ import hostgroupsQuery from './hostgroups.gql';
|
|
|
25
25
|
|
|
26
26
|
export const maxResults = 100;
|
|
27
27
|
|
|
28
|
+
const queries = {
|
|
29
|
+
HOSTS: hostsQuery,
|
|
30
|
+
HOST_GROUPS: hostgroupsQuery,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const dataName = {
|
|
34
|
+
HOSTS: 'hosts',
|
|
35
|
+
HOST_GROUPS: 'hostgroups',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const useNameSearch = queryKey => {
|
|
39
|
+
const org = useForemanOrganization();
|
|
40
|
+
const location = useForemanLocation();
|
|
41
|
+
const [search, setSearch] = useState('');
|
|
42
|
+
|
|
43
|
+
const { loading, data, error } = useQuery(queries[queryKey], {
|
|
44
|
+
variables: {
|
|
45
|
+
search: [
|
|
46
|
+
`name~"${search}"`,
|
|
47
|
+
org ? `organization_id=${org.id}` : null,
|
|
48
|
+
location ? `location_id=${location.id}` : null,
|
|
49
|
+
]
|
|
50
|
+
.filter(i => i)
|
|
51
|
+
.join(' and '),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return [
|
|
55
|
+
setSearch,
|
|
56
|
+
{
|
|
57
|
+
subtotal: data?.[dataName[queryKey]]?.totalCount,
|
|
58
|
+
results:
|
|
59
|
+
data?.[dataName[queryKey]]?.nodes.map(node => ({
|
|
60
|
+
id: decodeId(node.id),
|
|
61
|
+
name: node.name,
|
|
62
|
+
displayName: node.displayName,
|
|
63
|
+
})) || [],
|
|
64
|
+
error: error?.message || null,
|
|
65
|
+
},
|
|
66
|
+
loading,
|
|
67
|
+
];
|
|
68
|
+
};
|
|
69
|
+
|
|
28
70
|
export const SearchSelect = ({
|
|
29
71
|
name,
|
|
30
72
|
selected,
|
|
31
73
|
setSelected,
|
|
32
74
|
placeholderText,
|
|
33
75
|
apiKey,
|
|
34
|
-
url,
|
|
35
76
|
setLabel,
|
|
36
77
|
}) => {
|
|
37
|
-
const
|
|
38
|
-
const org = useForemanOrganization();
|
|
39
|
-
const location = useForemanLocation();
|
|
40
|
-
const [search, setSearch] = useState('');
|
|
41
|
-
const queries = {
|
|
42
|
-
HOSTS: hostsQuery,
|
|
43
|
-
HOST_GROUPS: hostgroupsQuery,
|
|
44
|
-
};
|
|
45
|
-
// Was from JobWizardConstants. Move into ours maybe.
|
|
46
|
-
const dataName = {
|
|
47
|
-
HOSTS: 'hosts',
|
|
48
|
-
HOST_GROUPS: 'hostgroups',
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const { loading, data } = useQuery(queries[queryKey], {
|
|
52
|
-
variables: {
|
|
53
|
-
search: [
|
|
54
|
-
`name~"${search}"`,
|
|
55
|
-
org ? `organization_id=${org.id}` : null,
|
|
56
|
-
location ? `location_id=${location.id}` : null,
|
|
57
|
-
]
|
|
58
|
-
.filter(i => i)
|
|
59
|
-
.join(' and '),
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
return [
|
|
63
|
-
setSearch,
|
|
64
|
-
{
|
|
65
|
-
subtotal: data?.[dataName[queryKey]]?.totalCount,
|
|
66
|
-
results:
|
|
67
|
-
data?.[dataName[queryKey]]?.nodes.map(node => ({
|
|
68
|
-
id: decodeId(node.id),
|
|
69
|
-
name: node.name,
|
|
70
|
-
displayName: node.displayName,
|
|
71
|
-
})) || [],
|
|
72
|
-
},
|
|
73
|
-
loading,
|
|
74
|
-
];
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const [onSearch, response, isLoading] = useNameSearch(apiKey, url);
|
|
78
|
+
const [onSearch, response, isLoading] = useNameSearch(apiKey);
|
|
78
79
|
const [inputValue, setInputValue] = useState('');
|
|
79
80
|
const [isOpen, setIsOpen] = useState(false);
|
|
80
|
-
const
|
|
81
|
+
const typingTimeoutRef = useRef(null);
|
|
82
|
+
|
|
81
83
|
useEffect(() => {
|
|
82
|
-
onSearch(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
return undefined;
|
|
84
|
+
onSearch('');
|
|
85
|
+
return () => {
|
|
86
|
+
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
|
87
|
+
};
|
|
87
88
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
88
89
|
}, []);
|
|
90
|
+
|
|
89
91
|
let selectOptions = [];
|
|
90
|
-
|
|
92
|
+
|
|
93
|
+
if (response.error) {
|
|
94
|
+
selectOptions = [
|
|
95
|
+
<SelectOption isDisabled key="error">
|
|
96
|
+
{sprintf(__('Error loading results: %s'), response.error)}
|
|
97
|
+
</SelectOption>,
|
|
98
|
+
];
|
|
99
|
+
} else if (response.subtotal > maxResults) {
|
|
91
100
|
selectOptions = [
|
|
92
101
|
<SelectOption
|
|
93
102
|
isDisabled
|
|
@@ -102,10 +111,11 @@ export const SearchSelect = ({
|
|
|
102
111
|
</SelectOption>,
|
|
103
112
|
];
|
|
104
113
|
}
|
|
114
|
+
|
|
105
115
|
selectOptions = [
|
|
106
116
|
...selectOptions,
|
|
107
117
|
...Immutable.asMutable(response?.results || [])?.map((result, index) => (
|
|
108
|
-
<SelectOption key={
|
|
118
|
+
<SelectOption key={result.id || index} value={result.id}>
|
|
109
119
|
{setLabel(result)}
|
|
110
120
|
</SelectOption>
|
|
111
121
|
)),
|
|
@@ -124,9 +134,10 @@ export const SearchSelect = ({
|
|
|
124
134
|
}
|
|
125
135
|
setInputValue('');
|
|
126
136
|
};
|
|
137
|
+
|
|
127
138
|
const autoSearch = searchTerm => {
|
|
128
|
-
if (
|
|
129
|
-
|
|
139
|
+
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
|
140
|
+
typingTimeoutRef.current = setTimeout(() => onSearch(searchTerm), 500);
|
|
130
141
|
};
|
|
131
142
|
|
|
132
143
|
const toggle = toggleRef => (
|
|
@@ -149,17 +160,19 @@ export const SearchSelect = ({
|
|
|
149
160
|
aria-label={`${name} typeahead input`}
|
|
150
161
|
role="combobox"
|
|
151
162
|
isExpanded={isOpen}
|
|
152
|
-
aria-controls=
|
|
163
|
+
aria-controls={`${name}-listbox`}
|
|
153
164
|
placeholder={placeholderText}
|
|
154
165
|
/>
|
|
155
166
|
<TextInputGroupUtilities>
|
|
156
|
-
{isLoading &&
|
|
167
|
+
{isLoading && (
|
|
168
|
+
<Spinner size="md" aria-label={__('Loading results')} />
|
|
169
|
+
)}
|
|
157
170
|
{selected.length > 0 && (
|
|
158
171
|
<Button
|
|
159
172
|
variant="plain"
|
|
160
173
|
aria-label={__('Clear selections')}
|
|
161
174
|
onClick={() => {
|
|
162
|
-
setSelected([]);
|
|
175
|
+
setSelected(() => []);
|
|
163
176
|
setInputValue('');
|
|
164
177
|
}}
|
|
165
178
|
>
|
|
@@ -170,6 +183,7 @@ export const SearchSelect = ({
|
|
|
170
183
|
</TextInputGroup>
|
|
171
184
|
</MenuToggle>
|
|
172
185
|
);
|
|
186
|
+
|
|
173
187
|
return (
|
|
174
188
|
<Select
|
|
175
189
|
id={name}
|
|
@@ -177,11 +191,11 @@ export const SearchSelect = ({
|
|
|
177
191
|
selected={selected.map(({ id }) => id)}
|
|
178
192
|
onSelect={onSelect}
|
|
179
193
|
onOpenChange={setIsOpen}
|
|
180
|
-
role="
|
|
194
|
+
role="listbox"
|
|
181
195
|
toggle={toggle}
|
|
182
196
|
>
|
|
183
197
|
<SelectList
|
|
184
|
-
id=
|
|
198
|
+
id={`${name}-listbox`}
|
|
185
199
|
style={{ maxHeight: '45vh', overflowY: 'auto' }}
|
|
186
200
|
>
|
|
187
201
|
{selectOptions}
|
|
@@ -192,17 +206,15 @@ export const SearchSelect = ({
|
|
|
192
206
|
|
|
193
207
|
SearchSelect.propTypes = {
|
|
194
208
|
name: PropTypes.string,
|
|
195
|
-
selected: PropTypes.
|
|
209
|
+
selected: PropTypes.array,
|
|
196
210
|
setSelected: PropTypes.func.isRequired,
|
|
197
211
|
setLabel: PropTypes.func.isRequired,
|
|
198
212
|
placeholderText: PropTypes.string,
|
|
199
213
|
apiKey: PropTypes.string.isRequired,
|
|
200
|
-
url: PropTypes.string,
|
|
201
214
|
};
|
|
202
215
|
|
|
203
216
|
SearchSelect.defaultProps = {
|
|
204
217
|
name: 'typeahead select',
|
|
205
|
-
selected:
|
|
218
|
+
selected: [],
|
|
206
219
|
placeholderText: '',
|
|
207
|
-
url: '',
|
|
208
220
|
};
|
|
@@ -17,17 +17,17 @@ const SelectedChip = ({ selected, setSelected, categoryName, setLabel }) => {
|
|
|
17
17
|
className="hosts-chip-group"
|
|
18
18
|
categoryName={categoryName}
|
|
19
19
|
isClosable
|
|
20
|
-
closeBtnAriaLabel=
|
|
20
|
+
closeBtnAriaLabel={__('Remove all')}
|
|
21
21
|
collapsedText={sprintf(__('%s more'), selected.length - NUM_CHIPS)}
|
|
22
22
|
numChips={NUM_CHIPS}
|
|
23
23
|
onClick={() => {
|
|
24
24
|
setSelected(() => []);
|
|
25
25
|
}}
|
|
26
26
|
>
|
|
27
|
-
{selected.map(
|
|
27
|
+
{selected.map(result => (
|
|
28
28
|
<Chip
|
|
29
29
|
ouiaId={`${categoryName}-${result.id}`}
|
|
30
|
-
key={
|
|
30
|
+
key={result.id}
|
|
31
31
|
id={`${categoryName}-${result.id}`}
|
|
32
32
|
onClick={() => deleteItem(result.id)}
|
|
33
33
|
closeBtnAriaLabel={`Remove ${result.name}`}
|
|
@@ -71,16 +71,14 @@ export const SelectedChips = ({
|
|
|
71
71
|
setSelected={setSelectedHostGroups}
|
|
72
72
|
setLabel={setLabel}
|
|
73
73
|
/>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
hostsSearchQuery
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
setLabel={setLabel}
|
|
83
|
-
/>
|
|
74
|
+
{hostsSearchQuery && (
|
|
75
|
+
<SelectedChip
|
|
76
|
+
selected={[{ id: hostsSearchQuery, name: hostsSearchQuery }]}
|
|
77
|
+
categoryName={__('Search query')}
|
|
78
|
+
setSelected={() => clearSearch()}
|
|
79
|
+
setLabel={setLabel}
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
84
82
|
{showClear && (
|
|
85
83
|
<Button
|
|
86
84
|
ouiaId="clear-chips"
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import React, { useState, useEffect } from 'react';
|
|
6
6
|
import PropTypes from 'prop-types';
|
|
7
|
-
import {
|
|
7
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
8
8
|
import { API } from 'foremanReact/redux/API';
|
|
9
9
|
import {
|
|
10
10
|
FormGroup,
|
|
11
11
|
HelperText,
|
|
12
|
+
HelperTextItem,
|
|
12
13
|
Select,
|
|
13
14
|
SelectOption,
|
|
14
15
|
InputGroup,
|
|
@@ -17,24 +18,25 @@ import {
|
|
|
17
18
|
FormHelperText,
|
|
18
19
|
} from '@patternfly/react-core';
|
|
19
20
|
import { FilterIcon } from '@patternfly/react-icons';
|
|
21
|
+
import { extractErrorMessage } from '../../common/helpers';
|
|
20
22
|
import { SearchSelect } from './SearchSelect';
|
|
21
23
|
import { SelectedChips } from './SelectedChips';
|
|
22
24
|
import { HostSearch } from './HostSearch';
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
searchQuery: __('Search query'),
|
|
26
|
+
const HOST_METHOD_LABELS = {
|
|
27
|
+
hosts: () => __('Hosts'),
|
|
28
|
+
hostGroups: () => __('Host groups'),
|
|
29
|
+
searchQuery: () => __('Search query'),
|
|
29
30
|
};
|
|
31
|
+
|
|
30
32
|
const ERROR_MESSAGES = {
|
|
31
|
-
hosts: __('Please select at least one host'),
|
|
32
|
-
hostGroups: __('Please select at least one host group'),
|
|
33
|
-
searchQuery: __('Please enter a search query'),
|
|
33
|
+
hosts: () => __('Please select at least one host'),
|
|
34
|
+
hostGroups: () => __('Please select at least one host group'),
|
|
35
|
+
searchQuery: () => __('Please enter a search query'),
|
|
34
36
|
};
|
|
35
37
|
|
|
36
38
|
const HostSelector = ({ onChange, targetCount = 0 }) => {
|
|
37
|
-
const [hostMethod, setHostMethod] = useState(
|
|
39
|
+
const [hostMethod, setHostMethod] = useState('hosts');
|
|
38
40
|
const [isOpen, setIsOpen] = useState(false);
|
|
39
41
|
const [errorText, setErrorText] = useState('');
|
|
40
42
|
const [hostsSearchQuery, setHostsSearchQuery] = useState('');
|
|
@@ -121,7 +123,7 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
|
|
|
121
123
|
}
|
|
122
124
|
} catch (error) {
|
|
123
125
|
if (!cancelled) {
|
|
124
|
-
setFetchError(error
|
|
126
|
+
setFetchError(extractErrorMessage(error));
|
|
125
127
|
onChange([]);
|
|
126
128
|
}
|
|
127
129
|
} finally {
|
|
@@ -143,7 +145,7 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
|
|
|
143
145
|
const onSelect = (_event, selection) => {
|
|
144
146
|
setHostMethod(selection);
|
|
145
147
|
setIsOpen(false);
|
|
146
|
-
setErrorText(ERROR_MESSAGES[selection]
|
|
148
|
+
setErrorText(ERROR_MESSAGES[selection]());
|
|
147
149
|
};
|
|
148
150
|
|
|
149
151
|
const onToggleClick = () => setIsOpen(!isOpen);
|
|
@@ -154,25 +156,18 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
|
|
|
154
156
|
onClick={onToggleClick}
|
|
155
157
|
isExpanded={isOpen}
|
|
156
158
|
icon={<FilterIcon />}
|
|
159
|
+
aria-label={__('Select host targeting method')}
|
|
157
160
|
>
|
|
158
|
-
{hostMethod}
|
|
161
|
+
{HOST_METHOD_LABELS[hostMethod]()}
|
|
159
162
|
</MenuToggle>
|
|
160
163
|
);
|
|
161
164
|
|
|
162
165
|
return (
|
|
163
166
|
<div className="host-selector">
|
|
164
167
|
<FormGroup fieldId="host-selector" label={__('Hosts')}>
|
|
165
|
-
{targetCount > 0 && (
|
|
166
|
-
<HelperText>
|
|
167
|
-
<HelperText variant="success">
|
|
168
|
-
{sprintf(__('%s hosts selected'), targetCount)}
|
|
169
|
-
</HelperText>
|
|
170
|
-
</HelperText>
|
|
171
|
-
)}
|
|
172
|
-
|
|
173
168
|
{isLoading && (
|
|
174
|
-
<HelperText>
|
|
175
|
-
<
|
|
169
|
+
<HelperText aria-live="polite">
|
|
170
|
+
<HelperTextItem>{__('Loading hosts...')}</HelperTextItem>
|
|
176
171
|
</HelperText>
|
|
177
172
|
)}
|
|
178
173
|
|
|
@@ -188,16 +183,16 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
|
|
|
188
183
|
className="without_select2"
|
|
189
184
|
aria-label={__('Host selection method')}
|
|
190
185
|
>
|
|
191
|
-
{Object.
|
|
192
|
-
<SelectOption key={
|
|
193
|
-
{
|
|
186
|
+
{Object.entries(HOST_METHOD_LABELS).map(([key, labelFn]) => (
|
|
187
|
+
<SelectOption key={key} value={key}>
|
|
188
|
+
{labelFn()}
|
|
194
189
|
</SelectOption>
|
|
195
190
|
))}
|
|
196
191
|
</Select>
|
|
197
192
|
</FormGroup>
|
|
198
193
|
</InputGroupItem>
|
|
199
194
|
|
|
200
|
-
{hostMethod ===
|
|
195
|
+
{hostMethod === 'hosts' && (
|
|
201
196
|
<SearchSelect
|
|
202
197
|
selected={selectedTargets.hosts}
|
|
203
198
|
setSelected={setSelectedHosts}
|
|
@@ -208,7 +203,7 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
|
|
|
208
203
|
/>
|
|
209
204
|
)}
|
|
210
205
|
|
|
211
|
-
{hostMethod ===
|
|
206
|
+
{hostMethod === 'hostGroups' && (
|
|
212
207
|
<SearchSelect
|
|
213
208
|
selected={selectedTargets.hostGroups}
|
|
214
209
|
setSelected={setSelectedHostGroups}
|
|
@@ -219,7 +214,7 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
|
|
|
219
214
|
/>
|
|
220
215
|
)}
|
|
221
216
|
|
|
222
|
-
{hostMethod ===
|
|
217
|
+
{hostMethod === 'searchQuery' && (
|
|
223
218
|
<HostSearch
|
|
224
219
|
setValue={setHostsSearchQuery}
|
|
225
220
|
value={hostsSearchQuery}
|
|
@@ -228,14 +223,14 @@ const HostSelector = ({ onChange, targetCount = 0 }) => {
|
|
|
228
223
|
</InputGroup>
|
|
229
224
|
|
|
230
225
|
{!hasSelection && (
|
|
231
|
-
<FormHelperText>
|
|
232
|
-
<
|
|
226
|
+
<FormHelperText aria-live="assertive">
|
|
227
|
+
<HelperTextItem variant="error">{errorText}</HelperTextItem>
|
|
233
228
|
</FormHelperText>
|
|
234
229
|
)}
|
|
235
230
|
|
|
236
231
|
{fetchError && (
|
|
237
|
-
<FormHelperText>
|
|
238
|
-
<
|
|
232
|
+
<FormHelperText aria-live="assertive">
|
|
233
|
+
<HelperTextItem variant="error">{fetchError}</HelperTextItem>
|
|
239
234
|
</FormHelperText>
|
|
240
235
|
)}
|
|
241
236
|
</FormGroup>
|
|
@@ -9,7 +9,7 @@ import { ENCRYPTED_DEFAULT_PLACEHOLDER } from '../common/constants';
|
|
|
9
9
|
|
|
10
10
|
const Loading = () => (
|
|
11
11
|
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
12
|
-
<Spinner size="lg" />
|
|
12
|
+
<Spinner size="lg" aria-label={__('Loading OpenBolt options')} />
|
|
13
13
|
<p>{__('Loading OpenBolt options...')}</p>
|
|
14
14
|
</div>
|
|
15
15
|
);
|
|
@@ -74,7 +74,8 @@ const OpenBoltOptionsSection = ({
|
|
|
74
74
|
if (aIsBoolean !== bIsBoolean) return aIsBoolean ? -1 : 1;
|
|
75
75
|
return 0;
|
|
76
76
|
});
|
|
77
|
-
return [['transport', transport], ...entries];
|
|
77
|
+
if (transport) return [['transport', transport], ...entries];
|
|
78
|
+
return entries;
|
|
78
79
|
};
|
|
79
80
|
|
|
80
81
|
const render = () => {
|
|
@@ -105,6 +105,7 @@ const ParameterField = ({
|
|
|
105
105
|
isChecked={!!(value ?? defaultValue)}
|
|
106
106
|
onChange={(_event, checked) => onChange(name, checked)}
|
|
107
107
|
aria-label={name}
|
|
108
|
+
label={name}
|
|
108
109
|
/>
|
|
109
110
|
);
|
|
110
111
|
}
|
|
@@ -125,6 +126,7 @@ const ParameterField = ({
|
|
|
125
126
|
value={resolvedValue}
|
|
126
127
|
onChange={(_event, newValue) => onChange(name, newValue)}
|
|
127
128
|
isRequired={isRequired && !hasEncryptedDefault}
|
|
129
|
+
aria-label={description || name}
|
|
128
130
|
/>
|
|
129
131
|
);
|
|
130
132
|
};
|
|
@@ -15,10 +15,11 @@ const SmartProxySelect = ({
|
|
|
15
15
|
}) => (
|
|
16
16
|
<FormGroup label={__('Smart Proxy')} fieldId="smart-proxy-input">
|
|
17
17
|
<FormSelect
|
|
18
|
-
id="proxy-
|
|
18
|
+
id="smart-proxy-input"
|
|
19
19
|
value={selectedProxy}
|
|
20
20
|
onChange={onProxyChange}
|
|
21
21
|
isDisabled={isLoading}
|
|
22
|
+
aria-label={__('Select Smart Proxy')}
|
|
22
23
|
title={__('Select a Smart Proxy to run the task from.')}
|
|
23
24
|
// Foreman tries injecting select2 which breaks this component
|
|
24
25
|
className="without_select2"
|
|
@@ -24,9 +24,9 @@ const TaskSelect = ({
|
|
|
24
24
|
<Flex spaceItems={{ default: 'spaceItemsSm' }}>
|
|
25
25
|
<FlexItem flex={{ default: 'flex_1' }}>
|
|
26
26
|
<FormSelect
|
|
27
|
-
id="task-
|
|
27
|
+
id="task-name-input"
|
|
28
28
|
// Force remount on isDisabled so the tooltip based on the title changes
|
|
29
|
-
key={`task-
|
|
29
|
+
key={`task-name-input-${isDisabled}`}
|
|
30
30
|
title={
|
|
31
31
|
isDisabled
|
|
32
32
|
? __('You must first select a Smart Proxy')
|
|
@@ -67,7 +67,7 @@ const TaskSelect = ({
|
|
|
67
67
|
</FlexItem>
|
|
68
68
|
</Flex>
|
|
69
69
|
<span id="task-select-helper" className="pf-v5-u-screen-reader">
|
|
70
|
-
{__('Select
|
|
70
|
+
{__('Select an OpenBolt task to execute on the specified targets')}
|
|
71
71
|
</span>
|
|
72
72
|
</FormGroup>
|
|
73
73
|
);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import EmptyContent from '../EmptyContent';
|
|
4
|
+
|
|
5
|
+
describe('EmptyContent', () => {
|
|
6
|
+
test('renders the title text', () => {
|
|
7
|
+
render(<EmptyContent title="No items found" />);
|
|
8
|
+
expect(screen.getByText('No items found')).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import { createStore } from 'redux';
|
|
5
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
6
|
+
import { API } from 'foremanReact/redux/API';
|
|
7
|
+
import LaunchTask from '../index';
|
|
8
|
+
|
|
9
|
+
// Mock HostSelector to avoid Apollo/GraphQL dependency chain
|
|
10
|
+
jest.mock('../HostSelector', () => {
|
|
11
|
+
const MockHostSelector = () => (
|
|
12
|
+
<div data-testid="host-selector">Host Selector</div>
|
|
13
|
+
);
|
|
14
|
+
MockHostSelector.displayName = 'HostSelector';
|
|
15
|
+
return MockHostSelector;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const mockStore = createStore(() => ({}));
|
|
19
|
+
|
|
20
|
+
const renderLaunchTask = () =>
|
|
21
|
+
render(
|
|
22
|
+
<Provider store={mockStore}>
|
|
23
|
+
<MemoryRouter>
|
|
24
|
+
<LaunchTask />
|
|
25
|
+
</MemoryRouter>
|
|
26
|
+
</Provider>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
jest.clearAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('LaunchTask', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
// useSmartProxies fetches on mount
|
|
36
|
+
API.get.mockResolvedValue({
|
|
37
|
+
data: {
|
|
38
|
+
results: [
|
|
39
|
+
{ id: 1, name: 'proxy-one' },
|
|
40
|
+
{ id: 2, name: 'proxy-two' },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('renders the form with Launch Task button', async () => {
|
|
47
|
+
renderLaunchTask();
|
|
48
|
+
await waitFor(() => {
|
|
49
|
+
expect(screen.getByText(/Launch Task/)).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('renders Smart Proxy select with fetched proxies', async () => {
|
|
54
|
+
renderLaunchTask();
|
|
55
|
+
await waitFor(() => {
|
|
56
|
+
expect(screen.getByText('proxy-one')).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByText('proxy-two')).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('Launch Task button is disabled when form is incomplete', async () => {
|
|
62
|
+
renderLaunchTask();
|
|
63
|
+
await waitFor(() => {
|
|
64
|
+
const button = screen.getByRole('button', { name: /Launch Task/ });
|
|
65
|
+
expect(button).toBeDisabled();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('shows task select as disabled before proxy is selected', async () => {
|
|
70
|
+
renderLaunchTask();
|
|
71
|
+
await waitFor(() => {
|
|
72
|
+
const taskSelect = screen.getByLabelText('Select Task');
|
|
73
|
+
expect(taskSelect).toBeDisabled();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('renders host selector', async () => {
|
|
78
|
+
renderLaunchTask();
|
|
79
|
+
await waitFor(() => {
|
|
80
|
+
expect(screen.getByTestId('host-selector')).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
3
|
+
import ParameterField from '../ParameterField';
|
|
4
|
+
|
|
5
|
+
describe('ParameterField', () => {
|
|
6
|
+
test('renders text input for string type', () => {
|
|
7
|
+
const { container } = render(
|
|
8
|
+
<ParameterField
|
|
9
|
+
name="username"
|
|
10
|
+
metadata={{ type: 'String' }}
|
|
11
|
+
value="admin"
|
|
12
|
+
onChange={jest.fn()}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
const input = container.querySelector('input[type="text"]');
|
|
16
|
+
expect(input).toBeInTheDocument();
|
|
17
|
+
expect(input.value).toBe('admin');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('renders password input for sensitive fields', () => {
|
|
21
|
+
const { container } = render(
|
|
22
|
+
<ParameterField
|
|
23
|
+
name="password"
|
|
24
|
+
metadata={{ type: 'String', sensitive: true }}
|
|
25
|
+
value="secret"
|
|
26
|
+
onChange={jest.fn()}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
const input = container.querySelector('input[type="password"]');
|
|
30
|
+
expect(input).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('renders checkbox for boolean type', () => {
|
|
34
|
+
render(
|
|
35
|
+
<ParameterField
|
|
36
|
+
name="verbose"
|
|
37
|
+
metadata={{ type: 'boolean' }}
|
|
38
|
+
value
|
|
39
|
+
onChange={jest.fn()}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('renders checkbox for Optional[Boolean] type', () => {
|
|
46
|
+
render(
|
|
47
|
+
<ParameterField
|
|
48
|
+
name="noop"
|
|
49
|
+
metadata={{ type: 'Optional[Boolean]' }}
|
|
50
|
+
value={false}
|
|
51
|
+
onChange={jest.fn()}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('renders select for array (enum) type', () => {
|
|
58
|
+
const { container } = render(
|
|
59
|
+
<ParameterField
|
|
60
|
+
name="transport"
|
|
61
|
+
metadata={{ type: ['ssh', 'winrm'] }}
|
|
62
|
+
value="ssh"
|
|
63
|
+
onChange={jest.fn()}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
const select = container.querySelector('select');
|
|
67
|
+
expect(select).toBeInTheDocument();
|
|
68
|
+
expect(screen.getByText('ssh')).toBeInTheDocument();
|
|
69
|
+
expect(screen.getByText('winrm')).toBeInTheDocument();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('calls onChange when value changes', () => {
|
|
73
|
+
const handleChange = jest.fn();
|
|
74
|
+
const { container } = render(
|
|
75
|
+
<ParameterField
|
|
76
|
+
name="username"
|
|
77
|
+
metadata={{ type: 'String' }}
|
|
78
|
+
value=""
|
|
79
|
+
onChange={handleChange}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
const input = container.querySelector('input');
|
|
83
|
+
fireEvent.change(input, { target: { value: 'new-value' } });
|
|
84
|
+
expect(handleChange).toHaveBeenCalledWith('username', 'new-value');
|
|
85
|
+
});
|
|
86
|
+
});
|