foreman_openbolt 0.0.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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +619 -0
  3. data/README.md +46 -0
  4. data/Rakefile +106 -0
  5. data/app/controllers/foreman_openbolt/task_controller.rb +298 -0
  6. data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +40 -0
  7. data/app/lib/actions/foreman_openbolt/poll_task_status.rb +151 -0
  8. data/app/models/foreman_openbolt/task_job.rb +110 -0
  9. data/app/views/foreman_openbolt/react_page.html.erb +1 -0
  10. data/config/routes.rb +24 -0
  11. data/db/migrate/20250819000000_create_openbolt_task_jobs.rb +25 -0
  12. data/db/migrate/20250925000000_add_command_to_openbolt_task_jobs.rb +7 -0
  13. data/db/migrate/20251001000000_add_task_description_to_task_jobs.rb +7 -0
  14. data/db/seeds.d/001_add_openbolt_feature.rb +4 -0
  15. data/lib/foreman_openbolt/engine.rb +169 -0
  16. data/lib/foreman_openbolt/version.rb +5 -0
  17. data/lib/foreman_openbolt.rb +7 -0
  18. data/lib/proxy_api/openbolt.rb +53 -0
  19. data/lib/tasks/foreman_openbolt_tasks.rake +48 -0
  20. data/locale/Makefile +73 -0
  21. data/locale/en/foreman_openbolt.po +19 -0
  22. data/locale/foreman_openbolt.pot +19 -0
  23. data/locale/gemspec.rb +7 -0
  24. data/package.json +41 -0
  25. data/test/factories/foreman_openbolt_factories.rb +7 -0
  26. data/test/test_plugin_helper.rb +8 -0
  27. data/test/unit/foreman_openbolt_test.rb +13 -0
  28. data/webpack/global_index.js +4 -0
  29. data/webpack/global_test_setup.js +11 -0
  30. data/webpack/index.js +19 -0
  31. data/webpack/src/Components/LaunchTask/EmptyContent.js +24 -0
  32. data/webpack/src/Components/LaunchTask/FieldTable.js +147 -0
  33. data/webpack/src/Components/LaunchTask/HostSelector/HostSearch.js +29 -0
  34. data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +208 -0
  35. data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +113 -0
  36. data/webpack/src/Components/LaunchTask/HostSelector/hostgroups.gql +9 -0
  37. data/webpack/src/Components/LaunchTask/HostSelector/hosts.gql +10 -0
  38. data/webpack/src/Components/LaunchTask/HostSelector/index.js +261 -0
  39. data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +116 -0
  40. data/webpack/src/Components/LaunchTask/ParameterField.js +145 -0
  41. data/webpack/src/Components/LaunchTask/ParametersSection.js +66 -0
  42. data/webpack/src/Components/LaunchTask/SmartProxySelect.js +51 -0
  43. data/webpack/src/Components/LaunchTask/TaskSelect.js +84 -0
  44. data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +63 -0
  45. data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +48 -0
  46. data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +64 -0
  47. data/webpack/src/Components/LaunchTask/index.js +333 -0
  48. data/webpack/src/Components/TaskExecution/ExecutionDetails.js +188 -0
  49. data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +99 -0
  50. data/webpack/src/Components/TaskExecution/LoadingIndicator.js +51 -0
  51. data/webpack/src/Components/TaskExecution/ResultDisplay.js +174 -0
  52. data/webpack/src/Components/TaskExecution/TaskDetails.js +99 -0
  53. data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +142 -0
  54. data/webpack/src/Components/TaskExecution/index.js +130 -0
  55. data/webpack/src/Components/TaskHistory/TaskPopover.js +95 -0
  56. data/webpack/src/Components/TaskHistory/index.js +199 -0
  57. data/webpack/src/Components/common/HostsPopover.js +49 -0
  58. data/webpack/src/Components/common/constants.js +44 -0
  59. data/webpack/src/Components/common/helpers.js +19 -0
  60. data/webpack/src/Pages/LaunchTaskPage.js +12 -0
  61. data/webpack/src/Pages/TaskExecutionPage.js +12 -0
  62. data/webpack/src/Pages/TaskHistoryPage.js +12 -0
  63. data/webpack/src/Router/routes.js +30 -0
  64. data/webpack/test_setup.js +17 -0
  65. data/webpack/webpack.config.js +7 -0
  66. metadata +208 -0
@@ -0,0 +1,261 @@
1
+ /* Note: A lot of this code was adapted from foreman_remote_execution,
2
+ * specifically the JobWizard HostsAndInputs step component. Major props
3
+ * to the contributors of that project for their work.
4
+ */
5
+ import React, { useState, useEffect } from 'react';
6
+ import PropTypes from 'prop-types';
7
+ import { sprintf, translate as __ } from 'foremanReact/common/I18n';
8
+ import { API } from 'foremanReact/redux/API';
9
+ import {
10
+ FormGroup,
11
+ HelperText,
12
+ Select,
13
+ SelectOption,
14
+ InputGroup,
15
+ InputGroupItem,
16
+ MenuToggle,
17
+ FormHelperText,
18
+ } from '@patternfly/react-core';
19
+ import { FilterIcon } from '@patternfly/react-icons';
20
+ import { SearchSelect } from './SearchSelect';
21
+ import { SelectedChips } from './SelectedChips';
22
+ import { HostSearch } from './HostSearch';
23
+
24
+ // Was from JobWizardConstants. Move into ours maybe.
25
+ const HOST_METHODS = {
26
+ hosts: __('Hosts'),
27
+ hostGroups: __('Host groups'),
28
+ searchQuery: __('Search query'),
29
+ };
30
+ 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'),
34
+ };
35
+
36
+ const HostSelector = ({ onChange, targetCount = 0 }) => {
37
+ const [hostMethod, setHostMethod] = useState(HOST_METHODS.hosts);
38
+ const [isOpen, setIsOpen] = useState(false);
39
+ const [errorText, setErrorText] = useState('');
40
+ const [hostsSearchQuery, setHostsSearchQuery] = useState('');
41
+ const [selectedTargets, setSelectedTargets] = useState({
42
+ hosts: [],
43
+ hostGroups: [],
44
+ });
45
+ const [isLoading, setIsLoading] = useState(false);
46
+ const [fetchError, setFetchError] = useState('');
47
+
48
+ const setLabel = result => result.displayName || result.name;
49
+
50
+ const setSelectedHosts = newHostsFn =>
51
+ setSelectedTargets(prev => ({
52
+ ...prev,
53
+ hosts: newHostsFn(prev.hosts),
54
+ }));
55
+
56
+ const setSelectedHostGroups = newHostGroupsFn =>
57
+ setSelectedTargets(prev => ({
58
+ ...prev,
59
+ hostGroups: newHostGroupsFn(prev.hostGroups),
60
+ }));
61
+
62
+ const clearSearch = () => {
63
+ setHostsSearchQuery('');
64
+ };
65
+
66
+ const hasSelection =
67
+ selectedTargets.hosts.length > 0 ||
68
+ selectedTargets.hostGroups.length > 0 ||
69
+ hostsSearchQuery.trim().length > 0;
70
+
71
+ // Build and fetch targets when selections change
72
+ useEffect(() => {
73
+ let cancelled = false;
74
+
75
+ const fetchTargets = async () => {
76
+ const searchParts = [];
77
+
78
+ // Add direct host names
79
+ if (selectedTargets.hosts.length > 0) {
80
+ const hostNames = selectedTargets.hosts
81
+ .map(h => `name = "${h.name.replace(/"/g, '\\"')}"`)
82
+ .join(' or ');
83
+ searchParts.push(`(${hostNames})`);
84
+ }
85
+
86
+ // Add host groups
87
+ if (selectedTargets.hostGroups.length > 0) {
88
+ const groupQueries = selectedTargets.hostGroups
89
+ .map(g => `hostgroup_fullname = "${g.name.replace(/"/g, '\\"')}"`)
90
+ .join(' or ');
91
+ searchParts.push(`(${groupQueries})`);
92
+ }
93
+
94
+ // Add custom search query
95
+ if (hostsSearchQuery.trim().length > 0) {
96
+ searchParts.push(`(${hostsSearchQuery.trim()})`);
97
+ }
98
+
99
+ if (searchParts.length === 0) {
100
+ onChange([]);
101
+ return;
102
+ }
103
+
104
+ setIsLoading(true);
105
+ setFetchError('');
106
+
107
+ try {
108
+ const finalSearch = searchParts.join(' or ');
109
+
110
+ const searchParams = new URLSearchParams({
111
+ search: finalSearch,
112
+ per_page: 1000,
113
+ thin: '1',
114
+ });
115
+ const response = await API.get(`/api/hosts?${searchParams.toString()}`);
116
+
117
+ if (!cancelled) {
118
+ const hostNames =
119
+ response.data?.results?.map(host => host.name) || [];
120
+ onChange(hostNames);
121
+ }
122
+ } catch (error) {
123
+ if (!cancelled) {
124
+ setFetchError(error.message || __('Failed to fetch hosts'));
125
+ onChange([]);
126
+ }
127
+ } finally {
128
+ if (!cancelled) {
129
+ setIsLoading(false);
130
+ }
131
+ }
132
+ };
133
+
134
+ // Debounce the fetch
135
+ const timeoutId = setTimeout(fetchTargets, 500);
136
+
137
+ return () => {
138
+ cancelled = true;
139
+ clearTimeout(timeoutId);
140
+ };
141
+ }, [selectedTargets, hostsSearchQuery, onChange]);
142
+
143
+ const onSelect = (_event, selection) => {
144
+ setHostMethod(selection);
145
+ setIsOpen(false);
146
+ setErrorText(ERROR_MESSAGES[selection] || '');
147
+ };
148
+
149
+ const onToggleClick = () => setIsOpen(!isOpen);
150
+
151
+ const toggle = toggleRef => (
152
+ <MenuToggle
153
+ ref={toggleRef}
154
+ onClick={onToggleClick}
155
+ isExpanded={isOpen}
156
+ icon={<FilterIcon />}
157
+ >
158
+ {hostMethod}
159
+ </MenuToggle>
160
+ );
161
+
162
+ return (
163
+ <div className="host-selector">
164
+ <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
+ {isLoading && (
174
+ <HelperText>
175
+ <HelperText>{__('Loading hosts...')}</HelperText>
176
+ </HelperText>
177
+ )}
178
+
179
+ <InputGroup>
180
+ <InputGroupItem>
181
+ <FormGroup fieldId="host_methods" isRequired>
182
+ <Select
183
+ ouiaId="host_methods"
184
+ selected={hostMethod}
185
+ onSelect={onSelect}
186
+ toggle={toggle}
187
+ isOpen={isOpen}
188
+ className="without_select2"
189
+ aria-label={__('Host selection method')}
190
+ >
191
+ {Object.values(HOST_METHODS).map((method, index) => (
192
+ <SelectOption key={index} value={method}>
193
+ {method}
194
+ </SelectOption>
195
+ ))}
196
+ </Select>
197
+ </FormGroup>
198
+ </InputGroupItem>
199
+
200
+ {hostMethod === HOST_METHODS.hosts && (
201
+ <SearchSelect
202
+ selected={selectedTargets.hosts}
203
+ setSelected={setSelectedHosts}
204
+ apiKey="HOSTS"
205
+ name="hosts"
206
+ placeholderText={__('Filter by hosts')}
207
+ setLabel={setLabel}
208
+ />
209
+ )}
210
+
211
+ {hostMethod === HOST_METHODS.hostGroups && (
212
+ <SearchSelect
213
+ selected={selectedTargets.hostGroups}
214
+ setSelected={setSelectedHostGroups}
215
+ apiKey="HOST_GROUPS"
216
+ name="host groups"
217
+ placeholderText={__('Filter by host groups')}
218
+ setLabel={setLabel}
219
+ />
220
+ )}
221
+
222
+ {hostMethod === HOST_METHODS.searchQuery && (
223
+ <HostSearch
224
+ setValue={setHostsSearchQuery}
225
+ value={hostsSearchQuery}
226
+ />
227
+ )}
228
+ </InputGroup>
229
+
230
+ {!hasSelection && (
231
+ <FormHelperText>
232
+ <HelperText variant="error">{errorText}</HelperText>
233
+ </FormHelperText>
234
+ )}
235
+
236
+ {fetchError && (
237
+ <FormHelperText>
238
+ <HelperText variant="error">{fetchError}</HelperText>
239
+ </FormHelperText>
240
+ )}
241
+ </FormGroup>
242
+
243
+ <SelectedChips
244
+ selectedHosts={selectedTargets.hosts}
245
+ setSelectedHosts={setSelectedHosts}
246
+ selectedHostGroups={selectedTargets.hostGroups}
247
+ setSelectedHostGroups={setSelectedHostGroups}
248
+ hostsSearchQuery={hostsSearchQuery}
249
+ clearSearch={clearSearch}
250
+ setLabel={setLabel}
251
+ />
252
+ </div>
253
+ );
254
+ };
255
+
256
+ HostSelector.propTypes = {
257
+ onChange: PropTypes.func.isRequired,
258
+ targetCount: PropTypes.number.isRequired,
259
+ };
260
+
261
+ export default HostSelector;
@@ -0,0 +1,116 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { translate as __ } from 'foremanReact/common/I18n';
4
+ import { FormGroup, Spinner } from '@patternfly/react-core';
5
+ import ParameterField from './ParameterField';
6
+ import FieldTable from './FieldTable';
7
+ import EmptyContent from './EmptyContent';
8
+ import { ENCRYPTED_DEFAULT_PLACEHOLDER } from '../common/constants';
9
+
10
+ const Loading = () => (
11
+ <div style={{ textAlign: 'center', padding: '2rem' }}>
12
+ <Spinner size="lg" />
13
+ <p>{__('Loading OpenBolt options...')}</p>
14
+ </div>
15
+ );
16
+
17
+ const Options = ({ sortedOptions, values, onChange }) => {
18
+ const transport = values?.transport;
19
+ const visibleOptions = sortedOptions.filter(([optionName, metadata]) => {
20
+ if (optionName === 'transport') return true;
21
+ if (!metadata.transport) return true;
22
+ return metadata.transport.includes(transport);
23
+ });
24
+
25
+ const rows = visibleOptions.map(([optionName, metadata]) => ({
26
+ key: optionName,
27
+ name: optionName,
28
+ valueCell: (
29
+ // We don't want to show the type for OpenBolt options as
30
+ // there are no complex types like there are for task parameters,
31
+ // so we omit it here. Also, none should be marked as required, since
32
+ // all options are optional except transport, which always has a value.
33
+ <ParameterField
34
+ name={optionName}
35
+ metadata={metadata}
36
+ value={values[optionName]}
37
+ onChange={onChange}
38
+ />
39
+ ),
40
+ description: metadata.description,
41
+ hasEncryptedDefault:
42
+ metadata.default && metadata.default === ENCRYPTED_DEFAULT_PLACEHOLDER,
43
+ }));
44
+
45
+ return <FieldTable rows={rows} />;
46
+ };
47
+
48
+ Options.propTypes = {
49
+ sortedOptions: PropTypes.arrayOf(PropTypes.array).isRequired,
50
+ values: PropTypes.object.isRequired,
51
+ onChange: PropTypes.func.isRequired,
52
+ };
53
+
54
+ const OpenBoltOptionsSection = ({
55
+ selectedProxy,
56
+ openBoltOptionsMetadata,
57
+ openBoltOptions,
58
+ onOptionChange,
59
+ isLoading,
60
+ }) => {
61
+ const isBooleanType = metadata =>
62
+ metadata.type === 'boolean' || metadata.type === 'Optional[Boolean]';
63
+
64
+ // Sort options to group booleans at the top to make the UI cleaner
65
+ const sortedOptions = metadata => {
66
+ if (!metadata) return [];
67
+ const { transport, ...rest } = metadata;
68
+ const entries = Object.entries(rest);
69
+
70
+ entries.sort(([_nameA, metadataA], [_nameB, metadataB]) => {
71
+ const aIsBoolean = isBooleanType(metadataA);
72
+ const bIsBoolean = isBooleanType(metadataB);
73
+
74
+ if (aIsBoolean !== bIsBoolean) return aIsBoolean ? -1 : 1;
75
+ return 0;
76
+ });
77
+ return [['transport', transport], ...entries];
78
+ };
79
+
80
+ const render = () => {
81
+ if (isLoading) return <Loading />;
82
+ if (!selectedProxy)
83
+ return (
84
+ <EmptyContent
85
+ title={__('Select a Smart Proxy to see OpenBolt options')}
86
+ />
87
+ );
88
+ const options = sortedOptions(openBoltOptionsMetadata);
89
+ if (options.length === 0)
90
+ return <EmptyContent title={__('No OpenBolt options available')} />;
91
+
92
+ return (
93
+ <Options
94
+ sortedOptions={options}
95
+ values={openBoltOptions}
96
+ onChange={onOptionChange}
97
+ />
98
+ );
99
+ };
100
+
101
+ return (
102
+ <FormGroup label={__('OpenBolt Options')} fieldId="openbolt-options">
103
+ {render()}
104
+ </FormGroup>
105
+ );
106
+ };
107
+
108
+ OpenBoltOptionsSection.propTypes = {
109
+ selectedProxy: PropTypes.string.isRequired,
110
+ openBoltOptionsMetadata: PropTypes.object.isRequired,
111
+ openBoltOptions: PropTypes.object.isRequired,
112
+ onOptionChange: PropTypes.func.isRequired,
113
+ isLoading: PropTypes.bool.isRequired,
114
+ };
115
+
116
+ export default OpenBoltOptionsSection;
@@ -0,0 +1,145 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import {
4
+ Checkbox,
5
+ FormSelect,
6
+ FormSelectOption,
7
+ TextInput,
8
+ } from '@patternfly/react-core';
9
+ import { ENCRYPTED_DEFAULT_PLACEHOLDER } from '../common/constants';
10
+
11
+ /* Example task parameter metadata from the proxy:
12
+ * {
13
+ * "action": {
14
+ * "description": "An action to take",
15
+ * "type": "Enum[get, set, delete]"
16
+ * },
17
+ * "section": {
18
+ * "description": "The section to modify",
19
+ * "type": "Optional[String[1]]"
20
+ * },
21
+ * "value": {
22
+ * "description": "The value to set",
23
+ * "type": "Variant[String[1],Integer[1,99]]"
24
+ * },
25
+ * "isEnabled": {
26
+ * "description": "Whether the section is enabled",
27
+ * "type": "Optional[Boolean]"
28
+ * }
29
+ * }
30
+ *
31
+ * Example OpenBolt Options parameter metadata from the proxy (see OPENBOLT_OPTIONS in main.rb of the proxy code):
32
+ * {
33
+ * "noop": {
34
+ * "type": "boolean",
35
+ * "transport": ["ssh", "winrm"],
36
+ * "sensitive": false
37
+ * },
38
+ * "user": {
39
+ * "type": "string",
40
+ * "transport": ["ssh", "winrm"],
41
+ * "sensitive": false
42
+ * },
43
+ * "transport": {
44
+ * "type": ["ssh", "winrm"],
45
+ * "transport": ["ssh", "winrm"],
46
+ * "sensitive": false,
47
+ * "default": "ssh"
48
+ * },
49
+ * "password": {
50
+ * "type": "string",
51
+ * "transport": ["ssh", "winrm"],
52
+ * "sensitive": true
53
+ * }
54
+ * }
55
+ */
56
+
57
+ // TODO: The "required" attribute is being ignored and you can still submit
58
+ // the form to run a task without filling in required parameters. Need to figure
59
+ // out why the browser isn't enforcing this.
60
+ const ParameterField = ({
61
+ name,
62
+ metadata,
63
+ value,
64
+ onChange,
65
+ isRequired = false,
66
+ }) => {
67
+ const {
68
+ type,
69
+ sensitive,
70
+ default: defaultValue = null,
71
+ description = null,
72
+ } = metadata;
73
+
74
+ const fieldId = `param_${name}`;
75
+ const hasEncryptedDefault = defaultValue === ENCRYPTED_DEFAULT_PLACEHOLDER;
76
+
77
+ // Enums (arrays of strings) are rendered as dropdowns. We don't show
78
+ // the type label for these since the options are self-evident. Also
79
+ // no encrypted values.
80
+ if (Array.isArray(type)) {
81
+ return (
82
+ <FormSelect
83
+ id={fieldId}
84
+ aria-label={description || name}
85
+ title={description || name}
86
+ value={value ?? defaultValue ?? ''}
87
+ onChange={(_event, val) => onChange(name, val)}
88
+ isRequired={isRequired}
89
+ className="without_select2"
90
+ >
91
+ {type.map(option => (
92
+ <FormSelectOption key={option} value={option} label={option} />
93
+ ))}
94
+ </FormSelect>
95
+ );
96
+ }
97
+
98
+ // Booleans are rendered as checkboxes. No type label or encrypted values.
99
+ // PatternFly's Checkbox looks like absolute hot garbage by default. This
100
+ // inlines it with the label to make it look less awful.
101
+ if (type === 'boolean' || type === 'Optional[Boolean]') {
102
+ return (
103
+ <Checkbox
104
+ id={fieldId}
105
+ isChecked={!!(value ?? defaultValue)}
106
+ onChange={(_event, checked) => onChange(name, checked)}
107
+ aria-label={name}
108
+ />
109
+ );
110
+ }
111
+
112
+ // Everything else is a text input of some kind, at least for now.
113
+ // These can have encrypted defaults. When the user hasn't input
114
+ // a new value, and we have an encrypted default, we don't want to
115
+ // set the default value and instead set an empty string. We inject
116
+ // the actual default value in the controller.
117
+ const isBlank = v => v === undefined || v === '';
118
+ const fallback = hasEncryptedDefault ? '' : defaultValue ?? '';
119
+ const resolvedValue = isBlank(value) ? fallback : value;
120
+
121
+ return (
122
+ <TextInput
123
+ id={fieldId}
124
+ type={sensitive ? 'password' : 'text'}
125
+ value={resolvedValue}
126
+ onChange={(_event, newValue) => onChange(name, newValue)}
127
+ isRequired={isRequired && !hasEncryptedDefault}
128
+ />
129
+ );
130
+ };
131
+
132
+ ParameterField.propTypes = {
133
+ name: PropTypes.string.isRequired,
134
+ metadata: PropTypes.object.isRequired,
135
+ value: PropTypes.any,
136
+ onChange: PropTypes.func.isRequired,
137
+ isRequired: PropTypes.bool,
138
+ };
139
+
140
+ ParameterField.defaultProps = {
141
+ value: undefined,
142
+ isRequired: false,
143
+ };
144
+
145
+ export default ParameterField;
@@ -0,0 +1,66 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { translate as __ } from 'foremanReact/common/I18n';
4
+ import { FormGroup } from '@patternfly/react-core';
5
+ import ParameterField from './ParameterField';
6
+ import FieldTable from './FieldTable';
7
+ import EmptyContent from './EmptyContent';
8
+
9
+ const ParametersSection = ({
10
+ selectedTask,
11
+ taskMetadata,
12
+ taskParameters,
13
+ onParameterChange,
14
+ }) => {
15
+ const hasParameters =
16
+ selectedTask &&
17
+ taskMetadata[selectedTask]?.parameters &&
18
+ Object.keys(taskMetadata[selectedTask].parameters).length > 0;
19
+
20
+ const render = () => {
21
+ if (!selectedTask)
22
+ return <EmptyContent title={__('Select a task to see parameters')} />;
23
+ if (!hasParameters)
24
+ return <EmptyContent title={__('This task has no parameters')} />;
25
+ const entries = Object.entries(taskMetadata[selectedTask].parameters);
26
+ const rows = entries.map(([paramName, metadata]) => {
27
+ const isRequired = !metadata.type
28
+ ?.toString()
29
+ .toLowerCase()
30
+ .startsWith('optional');
31
+ return {
32
+ key: paramName,
33
+ name: paramName,
34
+ required: isRequired,
35
+ valueCell: (
36
+ <ParameterField
37
+ name={paramName}
38
+ metadata={metadata}
39
+ value={taskParameters[paramName]}
40
+ onChange={onParameterChange}
41
+ isRequired={isRequired}
42
+ />
43
+ ),
44
+ type: metadata.type,
45
+ description: metadata.description,
46
+ };
47
+ });
48
+
49
+ return <FieldTable rows={rows} />;
50
+ };
51
+
52
+ return (
53
+ <FormGroup label={__('Parameters')} fieldId="task-parameters">
54
+ {render()}
55
+ </FormGroup>
56
+ );
57
+ };
58
+
59
+ ParametersSection.propTypes = {
60
+ selectedTask: PropTypes.string.isRequired,
61
+ taskMetadata: PropTypes.object.isRequired,
62
+ taskParameters: PropTypes.object.isRequired,
63
+ onParameterChange: PropTypes.func.isRequired,
64
+ };
65
+
66
+ export default ParametersSection;
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { translate as __ } from 'foremanReact/common/I18n';
4
+ import {
5
+ FormGroup,
6
+ FormSelect,
7
+ FormSelectOption,
8
+ } from '@patternfly/react-core';
9
+
10
+ const SmartProxySelect = ({
11
+ smartProxies,
12
+ selectedProxy,
13
+ onProxyChange,
14
+ isLoading = false,
15
+ }) => (
16
+ <FormGroup label={__('Smart Proxy')} fieldId="smart-proxy-input">
17
+ <FormSelect
18
+ id="proxy-select"
19
+ value={selectedProxy}
20
+ onChange={onProxyChange}
21
+ isDisabled={isLoading}
22
+ title={__('Select a Smart Proxy to run the task from.')}
23
+ // Foreman tries injecting select2 which breaks this component
24
+ className="without_select2"
25
+ >
26
+ <FormSelectOption
27
+ key="select-smart-proxy"
28
+ value=""
29
+ label={isLoading ? __('Loading...') : __('Select Smart Proxy')}
30
+ isPlaceholder
31
+ />
32
+ {smartProxies.map(proxy => (
33
+ <FormSelectOption key={proxy.id} value={proxy.id} label={proxy.name} />
34
+ ))}
35
+ </FormSelect>
36
+ </FormGroup>
37
+ );
38
+
39
+ SmartProxySelect.propTypes = {
40
+ smartProxies: PropTypes.arrayOf(
41
+ PropTypes.shape({
42
+ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
43
+ name: PropTypes.string.isRequired,
44
+ })
45
+ ).isRequired,
46
+ selectedProxy: PropTypes.string.isRequired,
47
+ onProxyChange: PropTypes.func.isRequired,
48
+ isLoading: PropTypes.bool.isRequired,
49
+ };
50
+
51
+ export default SmartProxySelect;