foreman_remote_execution 14.1.4 → 15.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/api/v2/job_invocations_controller.rb +8 -0
  3. data/app/controllers/template_invocations_controller.rb +57 -0
  4. data/app/controllers/ui_job_wizard_controller.rb +6 -3
  5. data/app/helpers/remote_execution_helper.rb +5 -6
  6. data/app/views/api/v2/job_invocations/base.json.rabl +1 -1
  7. data/app/views/api/v2/job_invocations/hosts.json.rabl +1 -1
  8. data/app/views/api/v2/job_invocations/main.json.rabl +1 -1
  9. data/app/views/api/v2/job_invocations/show.json.rabl +18 -0
  10. data/app/views/templates/script/convert2rhel_analyze.erb +4 -4
  11. data/config/routes.rb +2 -0
  12. data/lib/foreman_remote_execution/engine.rb +3 -3
  13. data/lib/foreman_remote_execution/version.rb +1 -1
  14. data/webpack/JobInvocationDetail/JobAdditionInfo.js +214 -0
  15. data/webpack/JobInvocationDetail/JobInvocationConstants.js +40 -2
  16. data/webpack/JobInvocationDetail/JobInvocationDetail.scss +70 -0
  17. data/webpack/JobInvocationDetail/JobInvocationHostTable.js +177 -80
  18. data/webpack/JobInvocationDetail/JobInvocationHostTableToolbar.js +63 -0
  19. data/webpack/JobInvocationDetail/JobInvocationSelectors.js +8 -1
  20. data/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js +61 -10
  21. data/webpack/JobInvocationDetail/OpenAlInvocations.js +111 -0
  22. data/webpack/JobInvocationDetail/TemplateInvocation.js +202 -0
  23. data/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputCodeBlock.js +124 -0
  24. data/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js +156 -0
  25. data/webpack/JobInvocationDetail/TemplateInvocationComponents/PreviewTemplate.js +50 -0
  26. data/webpack/JobInvocationDetail/TemplateInvocationComponents/TemplateActionButtons.js +224 -0
  27. data/webpack/JobInvocationDetail/TemplateInvocationPage.js +53 -0
  28. data/webpack/JobInvocationDetail/__tests__/MainInformation.test.js +1 -1
  29. data/webpack/JobInvocationDetail/__tests__/OpenAlInvocations.test.js +110 -0
  30. data/webpack/JobInvocationDetail/__tests__/OutputCodeBlock.test.js +69 -0
  31. data/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js +131 -0
  32. data/webpack/JobInvocationDetail/__tests__/fixtures.js +130 -0
  33. data/webpack/JobInvocationDetail/index.js +18 -3
  34. data/webpack/JobWizard/JobWizard.js +38 -16
  35. data/webpack/JobWizard/{StartsBeforeErrorAlert.js → StartsErrorAlert.js} +16 -1
  36. data/webpack/JobWizard/steps/AdvancedFields/__tests__/AdvancedFields.test.js +1 -1
  37. data/webpack/JobWizard/steps/Schedule/ScheduleFuture.js +1 -1
  38. data/webpack/JobWizard/steps/Schedule/ScheduleRecurring.js +5 -3
  39. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +1 -1
  40. data/webpack/JobWizard/steps/Schedule/__tests__/Schedule.test.js +3 -3
  41. data/webpack/JobWizard/steps/form/DateTimePicker.js +13 -0
  42. data/webpack/JobWizard/steps/form/Formatter.js +1 -0
  43. data/webpack/JobWizard/steps/form/ResourceSelect.js +34 -9
  44. data/webpack/Routes/routes.js +6 -0
  45. data/webpack/__mocks__/foremanReact/Root/Context/ForemanContext/index.js +1 -0
  46. data/webpack/react_app/components/RegistrationExtension/RexPull.js +27 -2
  47. data/webpack/react_app/components/TargetingHosts/components/HostStatus.js +1 -1
  48. metadata +15 -3
@@ -0,0 +1,202 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { isEmpty } from 'lodash';
3
+ import PropTypes from 'prop-types';
4
+ import { ClipboardCopyButton, Alert, Skeleton } from '@patternfly/react-core';
5
+ import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
6
+ import { translate as __ } from 'foremanReact/common/I18n';
7
+ import { useForemanHostDetailsPageUrl } from 'foremanReact/Root/Context/ForemanContext';
8
+ import { STATUS } from 'foremanReact/constants';
9
+ import {
10
+ showTemplateInvocationUrl,
11
+ templateInvocationPageUrl,
12
+ GET_TEMPLATE_INVOCATION,
13
+ } from './JobInvocationConstants';
14
+ import { OutputToggleGroup } from './TemplateInvocationComponents/OutputToggleGroup';
15
+ import { PreviewTemplate } from './TemplateInvocationComponents/PreviewTemplate';
16
+ import { OutputCodeBlock } from './TemplateInvocationComponents/OutputCodeBlock';
17
+
18
+ const CopyToClipboard = ({ fullOutput }) => {
19
+ const clipboardCopyFunc = async (event, text) => {
20
+ try {
21
+ await navigator.clipboard.writeText(text.toString());
22
+ } catch (error) {
23
+ // eslint-disable-next-line no-console
24
+ console.error(error?.message);
25
+ }
26
+ };
27
+
28
+ const onClick = (event, text) => {
29
+ clipboardCopyFunc(event, text);
30
+ setCopied(true);
31
+ };
32
+ const [copied, setCopied] = React.useState(false);
33
+ return (
34
+ <ClipboardCopyButton
35
+ id="expandable-copy-button"
36
+ textId="code-content"
37
+ aria-label="Copy to clipboard"
38
+ onClick={e => onClick(e, fullOutput)}
39
+ exitDelay={copied ? 1500 : 600}
40
+ maxWidth="110px"
41
+ variant="plain"
42
+ onTooltipHidden={() => setCopied(false)}
43
+ >
44
+ {copied
45
+ ? __('Successfully copied to clipboard!')
46
+ : __('Copy to clipboard')}
47
+ </ClipboardCopyButton>
48
+ );
49
+ };
50
+ let intervalId;
51
+ export const TemplateInvocation = ({
52
+ hostID,
53
+ jobID,
54
+ isInTableView,
55
+ hostName,
56
+ }) => {
57
+ const templateURL = showTemplateInvocationUrl(hostID, jobID);
58
+ const hostDetailsPageUrl = useForemanHostDetailsPageUrl();
59
+ const { response, status, setAPIOptions } = useAPI('get', templateURL, {
60
+ key: GET_TEMPLATE_INVOCATION,
61
+ headers: { Accept: 'application/json' },
62
+ handleError: () => {
63
+ if (intervalId) clearInterval(intervalId);
64
+ },
65
+ });
66
+ const { finished, auto_refresh: autoRefresh } = response;
67
+
68
+ useEffect(() => {
69
+ if (!finished || autoRefresh) {
70
+ intervalId = setInterval(() => {
71
+ // Re call api
72
+ setAPIOptions(prevOptions => ({
73
+ ...prevOptions,
74
+ }));
75
+ }, 5000);
76
+ }
77
+ if (intervalId && finished && !autoRefresh) {
78
+ clearInterval(intervalId);
79
+ }
80
+ return () => {
81
+ clearInterval(intervalId);
82
+ };
83
+ }, [finished, autoRefresh, setAPIOptions]);
84
+
85
+ const errorMessage =
86
+ response?.response?.data?.error?.message ||
87
+ response?.response?.data?.error ||
88
+ JSON.stringify(response);
89
+ const {
90
+ preview,
91
+ output,
92
+ input_values: inputValues,
93
+ task_id: taskID,
94
+ task_cancellable: taskCancellable,
95
+ permissions,
96
+ } = response;
97
+ const [showOutputType, setShowOutputType] = useState({
98
+ stderr: true,
99
+ stdout: true,
100
+ debug: true,
101
+ });
102
+ const [showTemplatePreview, setShowTemplatePreview] = useState(false);
103
+ const [showCommand, setCommand] = useState(false);
104
+ if (status === STATUS.PENDING && isEmpty(response)) {
105
+ return <Skeleton />;
106
+ } else if (status === STATUS.ERROR) {
107
+ return (
108
+ <Alert
109
+ ouiaId="template-invocation-error-alert"
110
+ variant="danger"
111
+ title={__(
112
+ 'An error occurred while fetching the template invocation details.'
113
+ )}
114
+ >
115
+ {errorMessage}
116
+ </Alert>
117
+ );
118
+ }
119
+
120
+ return (
121
+ <div
122
+ id={`template-invocation-${hostID}`}
123
+ className={`template-invocation ${
124
+ isInTableView ? ' output-in-table-view' : ''
125
+ }`}
126
+ >
127
+ <OutputToggleGroup
128
+ showOutputType={showOutputType}
129
+ setShowOutputType={setShowOutputType}
130
+ setShowTemplatePreview={setShowTemplatePreview}
131
+ showTemplatePreview={showTemplatePreview}
132
+ showCommand={showCommand}
133
+ setShowCommand={setCommand}
134
+ newTabUrl={templateInvocationPageUrl(hostID, jobID)}
135
+ isInTableView={isInTableView}
136
+ copyToClipboard={
137
+ <CopyToClipboard
138
+ fullOutput={output
139
+ ?.filter(
140
+ ({ output_type: outputType }) => showOutputType[outputType]
141
+ )
142
+ .map(({ output: _output }) => _output)
143
+ .join('\n')}
144
+ />
145
+ }
146
+ taskID={taskID}
147
+ jobID={jobID}
148
+ hostID={hostID}
149
+ taskCancellable={taskCancellable}
150
+ permissions={permissions}
151
+ />
152
+ {!isInTableView && (
153
+ <div>
154
+ {__('Target:')}{' '}
155
+ <a href={`${hostDetailsPageUrl}${hostName}`}>{hostName}</a>
156
+ </div>
157
+ )}
158
+ {showTemplatePreview && <PreviewTemplate inputValues={inputValues} />}
159
+ {showCommand && (
160
+ <>
161
+ {preview?.status ? (
162
+ <Alert
163
+ variant="danger"
164
+ ouiaId="template-invocation-preview-alert"
165
+ title={preview.plain}
166
+ />
167
+ ) : (
168
+ <pre className="template-invocation-preview">{preview.plain}</pre>
169
+ )}
170
+ </>
171
+ )}
172
+ <OutputCodeBlock
173
+ code={output || []}
174
+ showOutputType={showOutputType}
175
+ scrollElement={
176
+ isInTableView
177
+ ? `#template-invocation-${hostID} .invocation-output`
178
+ : '#foreman-main-container'
179
+ }
180
+ />
181
+ </div>
182
+ );
183
+ };
184
+
185
+ TemplateInvocation.propTypes = {
186
+ hostID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
187
+ hostName: PropTypes.string, // only used when isInTableView is false
188
+ jobID: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
189
+ isInTableView: PropTypes.bool,
190
+ };
191
+
192
+ TemplateInvocation.defaultProps = {
193
+ isInTableView: true,
194
+ hostName: '',
195
+ };
196
+
197
+ CopyToClipboard.propTypes = {
198
+ fullOutput: PropTypes.string,
199
+ };
200
+ CopyToClipboard.defaultProps = {
201
+ fullOutput: '',
202
+ };
@@ -0,0 +1,124 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { Button } from '@patternfly/react-core';
4
+ import { translate as __ } from 'foremanReact/common/I18n';
5
+
6
+ export const OutputCodeBlock = ({ code, showOutputType, scrollElement }) => {
7
+ let lineCounter = 0;
8
+ // eslint-disable-next-line no-control-regex
9
+ const COLOR_PATTERN = /\x1b\[(\d+)m/g;
10
+ const CONSOLE_COLOR = {
11
+ '31': 'red',
12
+ '32': 'lightgreen',
13
+ '33': 'orange',
14
+ '34': 'deepskyblue',
15
+ '35': 'mediumpurple',
16
+ '36': 'cyan',
17
+ '37': 'grey',
18
+ '91': 'red',
19
+ '92': 'lightgreen',
20
+ '93': 'yellow',
21
+ '94': 'lightblue',
22
+ '95': 'violet',
23
+ '96': 'turquoise',
24
+ '0': 'default',
25
+ };
26
+
27
+ const colorizeLine = line => {
28
+ line = line.replace(COLOR_PATTERN, seq => {
29
+ const color = seq.match(/(\d+)m/)[1];
30
+ return `{{{format color:${color}}}}`;
31
+ });
32
+
33
+ let currentColor = 'default';
34
+ const parts = line.split(/({{{format.*?}}})/).filter(Boolean);
35
+ if (parts.length === 0) {
36
+ return <span>{'\n'}</span>;
37
+ }
38
+ // eslint-disable-next-line array-callback-return, consistent-return
39
+ return parts.map((consoleLine, index) => {
40
+ if (consoleLine.includes('{{{format')) {
41
+ const colorMatch = consoleLine.match(/color:(\d+)/);
42
+ if (colorMatch) {
43
+ const colorIndex = colorMatch[1];
44
+ currentColor = CONSOLE_COLOR[colorIndex] || 'default';
45
+ }
46
+ } else {
47
+ return (
48
+ <span key={index} style={{ color: currentColor }}>
49
+ {consoleLine.length ? consoleLine : '\n'}
50
+ </span>
51
+ );
52
+ }
53
+ });
54
+ };
55
+ const filteredCode = code.filter(
56
+ ({ output_type: outputType }) => showOutputType[outputType]
57
+ );
58
+ if (!filteredCode.length) {
59
+ return <div>{__('No output for the selected filters')}</div>;
60
+ }
61
+ const codeParse = filteredCode.map(line => {
62
+ if (line.output === '\n') {
63
+ return null;
64
+ }
65
+ const lineOutputs = line.output
66
+ .replace(/\r\n/g, '\n')
67
+ .replace(/\n$/, '')
68
+ .split('\n');
69
+ return lineOutputs.map((lineOutput, index) => {
70
+ lineCounter += 1;
71
+ return (
72
+ <div key={index} className={`line ${line.output_type}`}>
73
+ <span
74
+ className="counter"
75
+ title={new Date(line.timestamp * 1000).toISOString()}
76
+ >
77
+ {lineCounter.toString().padStart(4, '\u00A0')}:{' '}
78
+ </span>
79
+ <div className="content">{colorizeLine(lineOutput)}</div>
80
+ </div>
81
+ );
82
+ });
83
+ });
84
+ const scrollElementSeleceted = () => document.querySelector(scrollElement);
85
+ const onClickScrollToTop = () => {
86
+ scrollElementSeleceted().scrollTo(0, 0);
87
+ };
88
+ const onClickScrollToBottom = () => {
89
+ scrollElementSeleceted().scrollTo(0, scrollElementSeleceted().scrollHeight);
90
+ };
91
+ return (
92
+ <div className="invocation-output">
93
+ <Button
94
+ component="a"
95
+ href="#"
96
+ variant="link"
97
+ isInline
98
+ className="scroll-link"
99
+ onClick={onClickScrollToBottom}
100
+ ouiaId="scroll-to-bottom"
101
+ >
102
+ {__('Scroll to bottom')}
103
+ </Button>
104
+ {codeParse}
105
+ <Button
106
+ component="a"
107
+ href="#"
108
+ variant="link"
109
+ isInline
110
+ className="scroll-link"
111
+ onClick={onClickScrollToTop}
112
+ ouiaId="scroll-to-top"
113
+ >
114
+ {__('Scroll to top')}
115
+ </Button>
116
+ </div>
117
+ );
118
+ };
119
+
120
+ OutputCodeBlock.propTypes = {
121
+ code: PropTypes.array.isRequired,
122
+ showOutputType: PropTypes.object.isRequired,
123
+ scrollElement: PropTypes.string.isRequired,
124
+ };
@@ -0,0 +1,156 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import {
4
+ ToggleGroup,
5
+ ToggleGroupItem,
6
+ Flex,
7
+ FlexItem,
8
+ Button,
9
+ } from '@patternfly/react-core';
10
+ import { OutlinedWindowRestoreIcon } from '@patternfly/react-icons';
11
+ import { translate as __ } from 'foremanReact/common/I18n';
12
+ import { TemplateActionButtons } from './TemplateActionButtons';
13
+
14
+ export const OutputToggleGroup = ({
15
+ showOutputType,
16
+ setShowOutputType,
17
+ showTemplatePreview,
18
+ setShowTemplatePreview,
19
+ showCommand,
20
+ setShowCommand,
21
+ newTabUrl,
22
+ isInTableView,
23
+ copyToClipboard,
24
+ taskID,
25
+ jobID,
26
+ hostID,
27
+ taskCancellable,
28
+ permissions,
29
+ }) => {
30
+ const handleSTDERRClick = _isSelected => {
31
+ setShowOutputType(prevShowOutputType => ({
32
+ ...prevShowOutputType,
33
+ stderr: _isSelected,
34
+ }));
35
+ };
36
+
37
+ const handleSTDOUTClick = _isSelected => {
38
+ setShowOutputType(prevShowOutputType => ({
39
+ ...prevShowOutputType,
40
+ stdout: _isSelected,
41
+ }));
42
+ };
43
+ const handleDEBUGClick = _isSelected => {
44
+ setShowOutputType(prevShowOutputType => ({
45
+ ...prevShowOutputType,
46
+ debug: _isSelected,
47
+ }));
48
+ };
49
+ const handlePreviewTemplateClick = _isSelected => {
50
+ setShowTemplatePreview(_isSelected);
51
+ };
52
+ const handleCommandClick = _isSelected => {
53
+ setShowCommand(_isSelected);
54
+ };
55
+
56
+ const toggleGroupItems = {
57
+ stderr: {
58
+ id: 'stderr-toggle',
59
+ text: __('STDERR'),
60
+ onClick: handleSTDERRClick,
61
+ isSelected: showOutputType.stderr,
62
+ },
63
+ stdout: {
64
+ id: 'stdout-toggle',
65
+ text: __('STDOUT'),
66
+ onClick: handleSTDOUTClick,
67
+ isSelected: showOutputType.stdout,
68
+ },
69
+ debug: {
70
+ id: 'debug-toggle',
71
+ text: __('DEBUG'),
72
+ onClick: handleDEBUGClick,
73
+ isSelected: showOutputType.debug,
74
+ },
75
+ previewTemplate: {
76
+ id: 'preview-template-toggle',
77
+ text: __('Preview Template'),
78
+ onClick: handlePreviewTemplateClick,
79
+ isSelected: showTemplatePreview,
80
+ },
81
+ command: {
82
+ id: 'command-toggle',
83
+ text: __('Command'),
84
+ onClick: handleCommandClick,
85
+ isSelected: showCommand,
86
+ },
87
+ };
88
+
89
+ return (
90
+ <Flex>
91
+ <FlexItem>
92
+ <ToggleGroup>
93
+ {Object.values(toggleGroupItems).map(
94
+ ({ id, text, onClick, isSelected }) => (
95
+ <ToggleGroupItem
96
+ key={id}
97
+ text={text}
98
+ buttonId={id}
99
+ isSelected={isSelected}
100
+ onChange={onClick}
101
+ />
102
+ )
103
+ )}
104
+ </ToggleGroup>
105
+ </FlexItem>
106
+ {isInTableView ? null : (
107
+ <TemplateActionButtons
108
+ taskID={taskID}
109
+ jobID={jobID}
110
+ hostID={hostID}
111
+ taskCancellable={taskCancellable}
112
+ permissions={permissions}
113
+ />
114
+ )}
115
+ <FlexItem>{copyToClipboard}</FlexItem>
116
+ {isInTableView && (
117
+ <FlexItem>
118
+ <Button
119
+ title={__('Open in new tab')}
120
+ variant="link"
121
+ isInline
122
+ ouiaId="template-invocation-new-tab-button"
123
+ component="a"
124
+ href={newTabUrl}
125
+ target="_blank"
126
+ >
127
+ <OutlinedWindowRestoreIcon />
128
+ </Button>
129
+ </FlexItem>
130
+ )}
131
+ </Flex>
132
+ );
133
+ };
134
+
135
+ OutputToggleGroup.propTypes = {
136
+ showOutputType: PropTypes.shape({
137
+ stderr: PropTypes.bool,
138
+ stdout: PropTypes.bool,
139
+ debug: PropTypes.bool,
140
+ }).isRequired,
141
+ setShowOutputType: PropTypes.func.isRequired,
142
+ setShowTemplatePreview: PropTypes.func.isRequired,
143
+ showTemplatePreview: PropTypes.bool.isRequired,
144
+ showCommand: PropTypes.bool.isRequired,
145
+ setShowCommand: PropTypes.func.isRequired,
146
+ newTabUrl: PropTypes.string,
147
+ copyToClipboard: PropTypes.node.isRequired,
148
+ isInTableView: PropTypes.bool,
149
+ ...TemplateActionButtons.propTypes,
150
+ };
151
+
152
+ OutputToggleGroup.defaultProps = {
153
+ newTabUrl: null,
154
+ isInTableView: false,
155
+ ...TemplateActionButtons.defaultProps,
156
+ };
@@ -0,0 +1,50 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import {
4
+ TableComposable,
5
+ Thead,
6
+ Tr,
7
+ Th,
8
+ Tbody,
9
+ Td,
10
+ } from '@patternfly/react-table';
11
+ import { translate as __ } from 'foremanReact/common/I18n';
12
+
13
+ export const PreviewTemplate = ({ inputValues }) =>
14
+ inputValues.length ? (
15
+ <TableComposable
16
+ ouiaId="template-invocation-preview-table"
17
+ isStriped
18
+ variant="compact"
19
+ >
20
+ <Thead>
21
+ <Tr ouiaId="template-invocation-preview-table-head">
22
+ <Th modifier="fitContent">{__('User input')}</Th>
23
+ <Th modifier="fitContent">{__('Value')}</Th>
24
+ </Tr>
25
+ </Thead>
26
+ <Tbody>
27
+ {inputValues.map(({ name, value }, index) => (
28
+ <Tr
29
+ key={index}
30
+ ouiaId={`template-invocation-preview-table-row-${name}`}
31
+ >
32
+ <Td>
33
+ <b>{name}</b>
34
+ </Td>
35
+ <Td>{value}</Td>
36
+ </Tr>
37
+ ))}
38
+ </Tbody>
39
+ </TableComposable>
40
+ ) : (
41
+ <span>{__('No user input')}</span>
42
+ );
43
+
44
+ PreviewTemplate.propTypes = {
45
+ inputValues: PropTypes.array,
46
+ };
47
+
48
+ PreviewTemplate.defaultProps = {
49
+ inputValues: [],
50
+ };