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.
- checksums.yaml +7 -0
- data/LICENSE +619 -0
- data/README.md +46 -0
- data/Rakefile +106 -0
- data/app/controllers/foreman_openbolt/task_controller.rb +298 -0
- data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +40 -0
- data/app/lib/actions/foreman_openbolt/poll_task_status.rb +151 -0
- data/app/models/foreman_openbolt/task_job.rb +110 -0
- data/app/views/foreman_openbolt/react_page.html.erb +1 -0
- data/config/routes.rb +24 -0
- data/db/migrate/20250819000000_create_openbolt_task_jobs.rb +25 -0
- data/db/migrate/20250925000000_add_command_to_openbolt_task_jobs.rb +7 -0
- data/db/migrate/20251001000000_add_task_description_to_task_jobs.rb +7 -0
- data/db/seeds.d/001_add_openbolt_feature.rb +4 -0
- data/lib/foreman_openbolt/engine.rb +169 -0
- data/lib/foreman_openbolt/version.rb +5 -0
- data/lib/foreman_openbolt.rb +7 -0
- data/lib/proxy_api/openbolt.rb +53 -0
- data/lib/tasks/foreman_openbolt_tasks.rake +48 -0
- data/locale/Makefile +73 -0
- data/locale/en/foreman_openbolt.po +19 -0
- data/locale/foreman_openbolt.pot +19 -0
- data/locale/gemspec.rb +7 -0
- data/package.json +41 -0
- data/test/factories/foreman_openbolt_factories.rb +7 -0
- data/test/test_plugin_helper.rb +8 -0
- data/test/unit/foreman_openbolt_test.rb +13 -0
- data/webpack/global_index.js +4 -0
- data/webpack/global_test_setup.js +11 -0
- data/webpack/index.js +19 -0
- data/webpack/src/Components/LaunchTask/EmptyContent.js +24 -0
- data/webpack/src/Components/LaunchTask/FieldTable.js +147 -0
- data/webpack/src/Components/LaunchTask/HostSelector/HostSearch.js +29 -0
- data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +208 -0
- data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +113 -0
- data/webpack/src/Components/LaunchTask/HostSelector/hostgroups.gql +9 -0
- data/webpack/src/Components/LaunchTask/HostSelector/hosts.gql +10 -0
- data/webpack/src/Components/LaunchTask/HostSelector/index.js +261 -0
- data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +116 -0
- data/webpack/src/Components/LaunchTask/ParameterField.js +145 -0
- data/webpack/src/Components/LaunchTask/ParametersSection.js +66 -0
- data/webpack/src/Components/LaunchTask/SmartProxySelect.js +51 -0
- data/webpack/src/Components/LaunchTask/TaskSelect.js +84 -0
- data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +63 -0
- data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +48 -0
- data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +64 -0
- data/webpack/src/Components/LaunchTask/index.js +333 -0
- data/webpack/src/Components/TaskExecution/ExecutionDetails.js +188 -0
- data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +99 -0
- data/webpack/src/Components/TaskExecution/LoadingIndicator.js +51 -0
- data/webpack/src/Components/TaskExecution/ResultDisplay.js +174 -0
- data/webpack/src/Components/TaskExecution/TaskDetails.js +99 -0
- data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +142 -0
- data/webpack/src/Components/TaskExecution/index.js +130 -0
- data/webpack/src/Components/TaskHistory/TaskPopover.js +95 -0
- data/webpack/src/Components/TaskHistory/index.js +199 -0
- data/webpack/src/Components/common/HostsPopover.js +49 -0
- data/webpack/src/Components/common/constants.js +44 -0
- data/webpack/src/Components/common/helpers.js +19 -0
- data/webpack/src/Pages/LaunchTaskPage.js +12 -0
- data/webpack/src/Pages/TaskExecutionPage.js +12 -0
- data/webpack/src/Pages/TaskHistoryPage.js +12 -0
- data/webpack/src/Router/routes.js +30 -0
- data/webpack/test_setup.js +17 -0
- data/webpack/webpack.config.js +7 -0
- 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;
|