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,147 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import {
4
+ Table,
5
+ Thead,
6
+ Tbody,
7
+ Tr,
8
+ Th,
9
+ Td,
10
+ ExpandableRowContent,
11
+ } from '@patternfly/react-table';
12
+ import { HelperText, HelperTextItem } from '@patternfly/react-core';
13
+ import { translate as __ } from 'foremanReact/common/I18n';
14
+
15
+ /**
16
+ * FieldTable
17
+ * Renders rows as: [chevron][Name] [Value]
18
+ * Clicking the chevron expands a details row that spans all columns and shows Type + Description.
19
+ *
20
+ * PatternFly v5 expandable table references:
21
+ * - Make first cell expandable via Td expand prop
22
+ * - Wrap each parent/child pair in a Tbody with isExpanded
23
+ * - Place details inside <ExpandableRowContent>
24
+ */
25
+ const FieldTable = ({ rows }) => {
26
+ // Track expanded state per row key
27
+ const [expanded, setExpanded] = React.useState(() => ({}));
28
+
29
+ const toggle = rowKey => {
30
+ setExpanded(prev => ({ ...prev, [rowKey]: !prev[rowKey] }));
31
+ };
32
+
33
+ return (
34
+ <Table
35
+ variant="compact"
36
+ isExpandable
37
+ isStickyHeader
38
+ gridBreakPoint="grid-md"
39
+ style={{ wordBreak: 'break-word' }}
40
+ >
41
+ <Thead>
42
+ <Tr>
43
+ <Th aria-label="Row expand control" />
44
+ <Th width={25}>Name</Th>
45
+ <Th width={75}>Value</Th>
46
+ </Tr>
47
+ </Thead>
48
+
49
+ {rows.map(
50
+ // required is only relevant (currently) for ParametersSection
51
+ // hasEncryptedDefault is only relevant for OpenBoltOptionsSection
52
+ (
53
+ {
54
+ key,
55
+ name,
56
+ valueCell,
57
+ type,
58
+ description,
59
+ required,
60
+ hasEncryptedDefault,
61
+ },
62
+ rowIndex
63
+ ) => {
64
+ const rowKey = String(key || name || rowIndex);
65
+ const isExpanded = !!expanded[rowKey];
66
+
67
+ return (
68
+ <Tbody key={rowKey} isExpanded={isExpanded}>
69
+ <Tr>
70
+ <Td
71
+ expand={{
72
+ rowIndex,
73
+ isExpanded,
74
+ onToggle: () => toggle(rowKey),
75
+ }}
76
+ />
77
+ <Td dataLabel="Name">
78
+ <span className="pf-v5-u-font-family-monospace">{name}</span>
79
+ {required && (
80
+ <span
81
+ style={{
82
+ color: 'red',
83
+ marginLeft: '0.25rem',
84
+ }}
85
+ title="Required"
86
+ >
87
+ *
88
+ </span>
89
+ )}
90
+ </Td>
91
+ <Td dataLabel="Value">{valueCell}</Td>
92
+ </Tr>
93
+
94
+ {(type || description) && (
95
+ <Tr isExpanded={isExpanded}>
96
+ <Td noPadding colSpan={3}>
97
+ <ExpandableRowContent>
98
+ <HelperText component="ul">
99
+ {type && (
100
+ <HelperTextItem>
101
+ <strong>{__('Type:')}</strong> <code>{type}</code>
102
+ </HelperTextItem>
103
+ )}
104
+ {required && (
105
+ <HelperTextItem variant="error">
106
+ <strong>{__('This field is required')}</strong>
107
+ </HelperTextItem>
108
+ )}
109
+ {hasEncryptedDefault && (
110
+ <HelperTextItem variant="warning">
111
+ <strong>
112
+ {__(
113
+ 'This field has a saved, encrypted default value. To use this value, do not change the field.'
114
+ )}
115
+ </strong>
116
+ </HelperTextItem>
117
+ )}
118
+ {description && (
119
+ <HelperTextItem>{description}</HelperTextItem>
120
+ )}
121
+ </HelperText>
122
+ </ExpandableRowContent>
123
+ </Td>
124
+ </Tr>
125
+ )}
126
+ </Tbody>
127
+ );
128
+ }
129
+ )}
130
+ </Table>
131
+ );
132
+ };
133
+
134
+ FieldTable.propTypes = {
135
+ rows: PropTypes.arrayOf(
136
+ PropTypes.shape({
137
+ key: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
138
+ name: PropTypes.string.isRequired,
139
+ required: PropTypes.bool,
140
+ valueCell: PropTypes.node.isRequired,
141
+ type: PropTypes.string,
142
+ description: PropTypes.node,
143
+ })
144
+ ).isRequired,
145
+ };
146
+
147
+ export default FieldTable;
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import SearchBar from 'foremanReact/components/SearchBar';
4
+ import { getControllerSearchProps } from 'foremanReact/constants';
5
+
6
+ export const HostSearch = ({ value, setValue }) => {
7
+ const props = getControllerSearchProps('hosts', 'mainHostQuery');
8
+ return (
9
+ <div className="foreman-search-field">
10
+ <SearchBar
11
+ data={{
12
+ ...props,
13
+ autocomplete: {
14
+ id: 'mainHostQuery',
15
+ url: '/hosts/auto_complete_search',
16
+ searchQuery: value,
17
+ },
18
+ }}
19
+ onSearch={null}
20
+ onSearchChange={search => setValue(search)}
21
+ />
22
+ </div>
23
+ );
24
+ };
25
+
26
+ HostSearch.propTypes = {
27
+ value: PropTypes.string.isRequired,
28
+ setValue: PropTypes.func.isRequired,
29
+ };
@@ -0,0 +1,208 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { useQuery } from '@apollo/client';
4
+ import {
5
+ Select,
6
+ SelectOption,
7
+ SelectList,
8
+ MenuToggle,
9
+ TextInputGroup,
10
+ TextInputGroupMain,
11
+ Button,
12
+ Spinner,
13
+ TextInputGroupUtilities,
14
+ } from '@patternfly/react-core';
15
+ import {
16
+ useForemanOrganization,
17
+ useForemanLocation,
18
+ } from 'foremanReact/Root/Context/ForemanContext';
19
+ import { decodeId } from 'foremanReact/common/globalIdHelpers';
20
+ import { TimesIcon } from '@patternfly/react-icons';
21
+ import Immutable from 'seamless-immutable';
22
+ import { sprintf, translate as __ } from 'foremanReact/common/I18n';
23
+ import hostsQuery from './hosts.gql';
24
+ import hostgroupsQuery from './hostgroups.gql';
25
+
26
+ export const maxResults = 100;
27
+
28
+ export const SearchSelect = ({
29
+ name,
30
+ selected,
31
+ setSelected,
32
+ placeholderText,
33
+ apiKey,
34
+ url,
35
+ setLabel,
36
+ }) => {
37
+ const useNameSearch = queryKey => {
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 [inputValue, setInputValue] = useState('');
79
+ const [isOpen, setIsOpen] = useState(false);
80
+ const [typingTimeout, setTypingTimeout] = useState(null);
81
+ useEffect(() => {
82
+ onSearch(selected.name || '');
83
+ if (typingTimeout) {
84
+ return () => clearTimeout(typingTimeout);
85
+ }
86
+ return undefined;
87
+ // eslint-disable-next-line react-hooks/exhaustive-deps
88
+ }, []);
89
+ let selectOptions = [];
90
+ if (response.subtotal > maxResults) {
91
+ selectOptions = [
92
+ <SelectOption
93
+ isDisabled
94
+ key={0}
95
+ description={__('Please refine your search.')}
96
+ >
97
+ {sprintf(
98
+ __('You have %s results to display. Showing first %s results'),
99
+ response.subtotal,
100
+ maxResults
101
+ )}
102
+ </SelectOption>,
103
+ ];
104
+ }
105
+ selectOptions = [
106
+ ...selectOptions,
107
+ ...Immutable.asMutable(response?.results || [])?.map((result, index) => (
108
+ <SelectOption key={index + 1} value={result.id}>
109
+ {setLabel(result)}
110
+ </SelectOption>
111
+ )),
112
+ ];
113
+
114
+ const onSelect = (event, selection) => {
115
+ if (selected.map(({ id }) => id).includes(selection)) {
116
+ setSelected(currentSelected =>
117
+ currentSelected.filter(({ id }) => id !== selection)
118
+ );
119
+ } else {
120
+ setSelected(currentSelected => [
121
+ ...currentSelected,
122
+ response.results.find(r => r.id === selection),
123
+ ]);
124
+ }
125
+ setInputValue('');
126
+ };
127
+ const autoSearch = searchTerm => {
128
+ if (typingTimeout) clearTimeout(typingTimeout);
129
+ setTypingTimeout(setTimeout(() => onSearch(searchTerm), 500));
130
+ };
131
+
132
+ const toggle = toggleRef => (
133
+ <MenuToggle
134
+ ref={toggleRef}
135
+ variant="typeahead"
136
+ aria-label={`${name} toggle`}
137
+ onClick={() => setIsOpen(!isOpen)}
138
+ isExpanded={isOpen}
139
+ isFullWidth
140
+ >
141
+ <TextInputGroup isPlain>
142
+ <TextInputGroupMain
143
+ value={inputValue}
144
+ onClick={() => setIsOpen(!isOpen)}
145
+ onChange={(_event, value) => {
146
+ setInputValue(value);
147
+ autoSearch(value || '');
148
+ }}
149
+ aria-label={`${name} typeahead input`}
150
+ role="combobox"
151
+ isExpanded={isOpen}
152
+ aria-controls="select-typeahead-listbox"
153
+ placeholder={placeholderText}
154
+ />
155
+ <TextInputGroupUtilities>
156
+ {isLoading && <Spinner size="md" />}
157
+ {selected.length > 0 && (
158
+ <Button
159
+ variant="plain"
160
+ aria-label={__('Clear selections')}
161
+ onClick={() => {
162
+ setSelected([]);
163
+ setInputValue('');
164
+ }}
165
+ >
166
+ <TimesIcon />
167
+ </Button>
168
+ )}
169
+ </TextInputGroupUtilities>
170
+ </TextInputGroup>
171
+ </MenuToggle>
172
+ );
173
+ return (
174
+ <Select
175
+ id={name}
176
+ isOpen={isOpen}
177
+ selected={selected.map(({ id }) => id)}
178
+ onSelect={onSelect}
179
+ onOpenChange={setIsOpen}
180
+ role="menu"
181
+ toggle={toggle}
182
+ >
183
+ <SelectList
184
+ id="select-typeahead-listbox"
185
+ style={{ maxHeight: '45vh', overflowY: 'auto' }}
186
+ >
187
+ {selectOptions}
188
+ </SelectList>
189
+ </Select>
190
+ );
191
+ };
192
+
193
+ SearchSelect.propTypes = {
194
+ name: PropTypes.string,
195
+ selected: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
196
+ setSelected: PropTypes.func.isRequired,
197
+ setLabel: PropTypes.func.isRequired,
198
+ placeholderText: PropTypes.string,
199
+ apiKey: PropTypes.string.isRequired,
200
+ url: PropTypes.string,
201
+ };
202
+
203
+ SearchSelect.defaultProps = {
204
+ name: 'typeahead select',
205
+ selected: {},
206
+ placeholderText: '',
207
+ url: '',
208
+ };
@@ -0,0 +1,113 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { Chip, ChipGroup, Button } from '@patternfly/react-core';
4
+ import { sprintf, translate as __ } from 'foremanReact/common/I18n';
5
+
6
+ const SelectedChip = ({ selected, setSelected, categoryName, setLabel }) => {
7
+ const deleteItem = itemToRemove => {
8
+ setSelected(oldSelected =>
9
+ oldSelected.filter(({ id }) => id !== itemToRemove)
10
+ );
11
+ };
12
+ const NUM_CHIPS = 3;
13
+ return (
14
+ <>
15
+ <ChipGroup
16
+ ouiaId="hosts-chip-group"
17
+ className="hosts-chip-group"
18
+ categoryName={categoryName}
19
+ isClosable
20
+ closeBtnAriaLabel="Remove all"
21
+ collapsedText={sprintf(__('%s more'), selected.length - NUM_CHIPS)}
22
+ numChips={NUM_CHIPS}
23
+ onClick={() => {
24
+ setSelected(() => []);
25
+ }}
26
+ >
27
+ {selected.map((result, index) => (
28
+ <Chip
29
+ ouiaId={`${categoryName}-${result.id}`}
30
+ key={index}
31
+ id={`${categoryName}-${result.id}`}
32
+ onClick={() => deleteItem(result.id)}
33
+ closeBtnAriaLabel={`Remove ${result.name}`}
34
+ >
35
+ {setLabel(result)}
36
+ </Chip>
37
+ ))}
38
+ </ChipGroup>
39
+ {selected.length > 0 && <br />}
40
+ </>
41
+ );
42
+ };
43
+
44
+ export const SelectedChips = ({
45
+ selectedHosts,
46
+ setSelectedHosts,
47
+ selectedHostGroups,
48
+ setSelectedHostGroups,
49
+ hostsSearchQuery,
50
+ clearSearch,
51
+ setLabel,
52
+ }) => {
53
+ const clearAll = () => {
54
+ setSelectedHosts(() => []);
55
+ setSelectedHostGroups(() => []);
56
+ clearSearch();
57
+ };
58
+ const showClear =
59
+ selectedHosts.length || selectedHostGroups.length || hostsSearchQuery;
60
+ return (
61
+ <div className="selected-chips">
62
+ <SelectedChip
63
+ selected={selectedHosts}
64
+ categoryName={__('Hosts')}
65
+ setSelected={setSelectedHosts}
66
+ setLabel={setLabel}
67
+ />
68
+ <SelectedChip
69
+ selected={selectedHostGroups}
70
+ categoryName={__('Host groups')}
71
+ setSelected={setSelectedHostGroups}
72
+ setLabel={setLabel}
73
+ />
74
+ <SelectedChip
75
+ selected={
76
+ hostsSearchQuery
77
+ ? [{ id: hostsSearchQuery, name: hostsSearchQuery }]
78
+ : []
79
+ }
80
+ categoryName={__('Search query')}
81
+ setSelected={clearSearch}
82
+ setLabel={setLabel}
83
+ />
84
+ {showClear && (
85
+ <Button
86
+ ouiaId="clear-chips"
87
+ variant="link"
88
+ className="clear-chips"
89
+ onClick={clearAll}
90
+ >
91
+ {__('Clear all target selections')}
92
+ </Button>
93
+ )}
94
+ </div>
95
+ );
96
+ };
97
+
98
+ SelectedChips.propTypes = {
99
+ selectedHosts: PropTypes.array.isRequired,
100
+ setSelectedHosts: PropTypes.func.isRequired,
101
+ selectedHostGroups: PropTypes.array.isRequired,
102
+ setSelectedHostGroups: PropTypes.func.isRequired,
103
+ hostsSearchQuery: PropTypes.string.isRequired,
104
+ clearSearch: PropTypes.func.isRequired,
105
+ setLabel: PropTypes.func.isRequired,
106
+ };
107
+
108
+ SelectedChip.propTypes = {
109
+ categoryName: PropTypes.string.isRequired,
110
+ selected: PropTypes.array.isRequired,
111
+ setSelected: PropTypes.func.isRequired,
112
+ setLabel: PropTypes.func.isRequired,
113
+ };
@@ -0,0 +1,9 @@
1
+ query($search: String!) {
2
+ hostgroups(first: 100, last: 100, search: $search) {
3
+ totalCount
4
+ nodes {
5
+ id
6
+ name: title
7
+ }
8
+ }
9
+ }
@@ -0,0 +1,10 @@
1
+ query($search: String!) {
2
+ hosts(first: 100, last: 100, search: $search) {
3
+ totalCount
4
+ nodes {
5
+ id
6
+ name
7
+ displayName
8
+ }
9
+ }
10
+ }