foreman-tasks 12.2.3 → 12.2.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 200260a81901f7159c565c8188aa71de7b80bc0583b7cf3bf6432a2541130567
4
- data.tar.gz: 3cb1afe2a7706fe328155ac0b32da0864155dfcb9e97a7b026283ba020f41489
3
+ metadata.gz: 23f5d844abe59e29a92b15f62c0770e4c3c1b577a1315232c422d976fa77a07e
4
+ data.tar.gz: 5d6316649f6cb75894b966301e5d8abefa579d472ddb33052d06e9d32210b869
5
5
  SHA512:
6
- metadata.gz: 26157491f3be0ce0e7386008c492476db1e49d28d28934f1139daec25771626d3e7813c3e858b501020f719930ad4fee3fcf6a54850bb661d4104b0628676993
7
- data.tar.gz: d295b0470a35efb6a20801431614256612fdb16fb9f85f6c2c79ffdfaf0bd384656986d82f9d1bf0072b68366420884fbb552638152ccf9ca397f4c8a20a14c8
6
+ metadata.gz: 7b16c2a1cd40296f38805bddb2a96879ed09d4288e7dc03c8061ba6b561333c96f9e9aa213b583349bce844580b85cc24833ca49f617e67730d3e6b28edf4773
7
+ data.tar.gz: 2b147d1a8a95389cb7c6a12c4fefea577cdec04f8c0ca66315b4411e62f968b1255b4a82438c014300bfd8d67eb0b9a0bea6fcaf277f1eb2d7058a8feaf5375f
@@ -1,3 +1,3 @@
1
1
  module ForemanTasks
2
- VERSION = '12.2.3'.freeze
2
+ VERSION = '12.2.4'.freeze
3
3
  end
@@ -1,4 +1,3 @@
1
- import { testActionSnapshotWithFixtures } from '@theforeman/test';
2
1
  import { API } from 'foremanReact/redux/API';
3
2
  import {
4
3
  cancelTaskRequest,
@@ -6,6 +5,20 @@ import {
6
5
  forceCancelTaskRequest,
7
6
  unlockTaskRequest,
8
7
  } from './';
8
+ import {
9
+ TASKS_CANCEL_REQUEST,
10
+ TASKS_CANCEL_SUCCESS,
11
+ TASKS_CANCEL_FAILURE,
12
+ TASKS_RESUME_REQUEST,
13
+ TASKS_RESUME_SUCCESS,
14
+ TASKS_RESUME_FAILURE,
15
+ TASKS_FORCE_CANCEL_REQUEST,
16
+ TASKS_FORCE_CANCEL_SUCCESS,
17
+ TASKS_FORCE_CANCEL_FAILURE,
18
+ TASKS_UNLOCK_REQUEST,
19
+ TASKS_UNLOCK_SUCCESS,
20
+ TASKS_UNLOCK_FAILURE,
21
+ } from './TaskActionsConstants';
9
22
 
10
23
  jest.mock('foremanReact/components/common/table', () => ({
11
24
  getTableItemsAction: jest.fn(controller => controller),
@@ -21,48 +34,205 @@ jest.mock('foremanReact/components/ToastsList', () => ({
21
34
  }),
22
35
  }));
23
36
 
24
- const task = ['some-id', 'some-name'];
37
+ const taskId = 'some-id';
38
+ const taskName = 'some-name';
25
39
 
26
- const fixtures = {
27
- 'should cancelTaskRequest and succeed': () => cancelTaskRequest(...task),
28
- 'should cancelTaskRequest and fail': () => {
29
- API.post.mockImplementation(() =>
30
- Promise.reject(new Error('Network Error'))
31
- );
32
- return cancelTaskRequest(...task);
40
+ const toastAction = (message, type) => ({
41
+ type: 'TOASTS_ADD',
42
+ payload: {
43
+ message: {
44
+ message,
45
+ type,
46
+ },
33
47
  },
48
+ });
34
49
 
35
- 'should resumeTaskRequest and succeed': () => {
36
- API.post.mockImplementation(() => ({ data: 'some-data' }));
37
- return resumeTaskRequest(...task);
38
- },
39
- 'should resumeTaskRequest and fail': () => {
40
- API.post.mockImplementation(() =>
41
- Promise.reject(new Error('Network Error'))
42
- );
43
- return resumeTaskRequest(...task);
44
- },
45
- 'should forceCancelTaskRequest and succeed': () => {
46
- API.post.mockImplementation(() => ({ data: 'some-data' }));
47
- return forceCancelTaskRequest(...task);
48
- },
49
- 'should forceCancelTaskRequest and fail': () => {
50
- API.post.mockImplementation(() =>
51
- Promise.reject(new Error('Network Error'))
52
- );
53
- return forceCancelTaskRequest(...task);
54
- },
55
- 'should unlockTaskRequest and succeed': () => {
56
- API.post.mockImplementation(() => ({ data: 'some-data' }));
57
- return unlockTaskRequest(...task);
58
- },
59
- 'should unlockTaskRequest and fail': () => {
60
- API.post.mockImplementation(() =>
61
- Promise.reject(new Error('Network Error'))
62
- );
63
- return forceCancelTaskRequest(...task);
64
- },
65
- };
66
- describe('Tasks actions', () => {
67
- testActionSnapshotWithFixtures(fixtures);
50
+ describe('Task actions', () => {
51
+ beforeEach(() => {
52
+ API.post.mockReset();
53
+ API.post.mockResolvedValue({ data: 'some-data' });
54
+ });
55
+
56
+ describe('cancelTaskRequest', () => {
57
+ it('dispatches success actions and toasts when cancel succeeds', async () => {
58
+ const dispatch = jest.fn();
59
+
60
+ await cancelTaskRequest(taskId, taskName)(dispatch);
61
+
62
+ expect(API.post).toHaveBeenCalledWith(
63
+ `/foreman_tasks/tasks/${taskId}/cancel`
64
+ );
65
+ expect(dispatch).toHaveBeenCalledTimes(4);
66
+ expect(dispatch.mock.calls[0][0]).toEqual(
67
+ toastAction('Trying to cancel some-name task', 'info')
68
+ );
69
+ expect(dispatch.mock.calls[1][0]).toEqual({
70
+ type: TASKS_CANCEL_REQUEST,
71
+ });
72
+ expect(dispatch.mock.calls[2][0]).toEqual({
73
+ type: TASKS_CANCEL_SUCCESS,
74
+ });
75
+ expect(dispatch.mock.calls[3][0]).toEqual(
76
+ toastAction('some-name Task execution was cancelled', 'success')
77
+ );
78
+ });
79
+
80
+ it('dispatches failure actions and warning toast when cancel fails', async () => {
81
+ API.post.mockRejectedValue(new Error('Network Error'));
82
+ const dispatch = jest.fn();
83
+
84
+ await cancelTaskRequest(taskId, taskName)(dispatch);
85
+
86
+ expect(dispatch).toHaveBeenCalledTimes(4);
87
+ expect(dispatch.mock.calls[0][0]).toEqual(
88
+ toastAction('Trying to cancel some-name task', 'info')
89
+ );
90
+ expect(dispatch.mock.calls[1][0]).toEqual({
91
+ type: TASKS_CANCEL_REQUEST,
92
+ });
93
+ expect(dispatch.mock.calls[2][0]).toEqual({
94
+ type: TASKS_CANCEL_FAILURE,
95
+ payload: expect.any(Error),
96
+ });
97
+ expect(dispatch.mock.calls[3][0]).toEqual(
98
+ toastAction(
99
+ 'some-name Task execution task has to be cancellable',
100
+ 'warning'
101
+ )
102
+ );
103
+ });
104
+ });
105
+
106
+ describe('resumeTaskRequest', () => {
107
+ it('dispatches success actions and toast when resume succeeds', async () => {
108
+ const dispatch = jest.fn();
109
+
110
+ await resumeTaskRequest(taskId, taskName)(dispatch);
111
+
112
+ expect(API.post).toHaveBeenCalledWith(
113
+ `/foreman_tasks/tasks/${taskId}/resume`
114
+ );
115
+ expect(dispatch).toHaveBeenCalledTimes(3);
116
+ expect(dispatch.mock.calls[0][0]).toEqual({
117
+ type: TASKS_RESUME_REQUEST,
118
+ });
119
+ expect(dispatch.mock.calls[1][0]).toEqual({
120
+ type: TASKS_RESUME_SUCCESS,
121
+ });
122
+ expect(dispatch.mock.calls[2][0]).toEqual(
123
+ toastAction('some-name Task execution was resumed', 'success')
124
+ );
125
+ });
126
+
127
+ it('dispatches failure actions and error toast when resume fails', async () => {
128
+ API.post.mockRejectedValue(new Error('Network Error'));
129
+ const dispatch = jest.fn();
130
+
131
+ await resumeTaskRequest(taskId, taskName)(dispatch);
132
+
133
+ expect(dispatch).toHaveBeenCalledTimes(3);
134
+ expect(dispatch.mock.calls[0][0]).toEqual({
135
+ type: TASKS_RESUME_REQUEST,
136
+ });
137
+ expect(dispatch.mock.calls[1][0]).toEqual({
138
+ type: TASKS_RESUME_FAILURE,
139
+ payload: expect.any(Error),
140
+ });
141
+ expect(dispatch.mock.calls[2][0]).toEqual(
142
+ toastAction('some-name Task execution could not be resumed', 'error')
143
+ );
144
+ });
145
+ });
146
+
147
+ describe('forceCancelTaskRequest', () => {
148
+ it('dispatches success actions and toast when force cancel succeeds', async () => {
149
+ const dispatch = jest.fn();
150
+
151
+ await forceCancelTaskRequest(taskId, taskName)(dispatch);
152
+
153
+ expect(API.post).toHaveBeenCalledWith(
154
+ `/foreman_tasks/tasks/${taskId}/force_unlock`
155
+ );
156
+ expect(dispatch).toHaveBeenCalledTimes(3);
157
+ expect(dispatch.mock.calls[0][0]).toEqual({
158
+ type: TASKS_FORCE_CANCEL_REQUEST,
159
+ });
160
+ expect(dispatch.mock.calls[1][0]).toEqual({
161
+ type: TASKS_FORCE_CANCEL_SUCCESS,
162
+ });
163
+ expect(dispatch.mock.calls[2][0]).toEqual(
164
+ toastAction(
165
+ 'some-name Task execution resources were unlocked with force.',
166
+ 'success'
167
+ )
168
+ );
169
+ });
170
+
171
+ it('dispatches failure actions and warning toast when force cancel fails', async () => {
172
+ API.post.mockRejectedValue(new Error('Network Error'));
173
+ const dispatch = jest.fn();
174
+
175
+ await forceCancelTaskRequest(taskId, taskName)(dispatch);
176
+
177
+ expect(dispatch).toHaveBeenCalledTimes(3);
178
+ expect(dispatch.mock.calls[0][0]).toEqual({
179
+ type: TASKS_FORCE_CANCEL_REQUEST,
180
+ });
181
+ expect(dispatch.mock.calls[1][0]).toEqual({
182
+ type: TASKS_FORCE_CANCEL_FAILURE,
183
+ });
184
+ expect(dispatch.mock.calls[2][0]).toEqual(
185
+ toastAction(
186
+ 'some-name Task execution cannot be cancelled with force at the moment.',
187
+ 'warning'
188
+ )
189
+ );
190
+ });
191
+ });
192
+
193
+ describe('unlockTaskRequest', () => {
194
+ it('dispatches success actions and toast when unlock succeeds', async () => {
195
+ const dispatch = jest.fn();
196
+
197
+ await unlockTaskRequest(taskId, taskName)(dispatch);
198
+
199
+ expect(API.post).toHaveBeenCalledWith(
200
+ `/foreman_tasks/tasks/${taskId}/unlock`
201
+ );
202
+ expect(dispatch).toHaveBeenCalledTimes(3);
203
+ expect(dispatch.mock.calls[0][0]).toEqual({
204
+ type: TASKS_UNLOCK_REQUEST,
205
+ });
206
+ expect(dispatch.mock.calls[1][0]).toEqual({
207
+ type: TASKS_UNLOCK_SUCCESS,
208
+ });
209
+ expect(dispatch.mock.calls[2][0]).toEqual(
210
+ toastAction(
211
+ 'some-name Task execution resources were unlocked ',
212
+ 'success'
213
+ )
214
+ );
215
+ });
216
+
217
+ it('dispatches failure actions and warning toast when unlock fails', async () => {
218
+ API.post.mockRejectedValue(new Error('Network Error'));
219
+ const dispatch = jest.fn();
220
+
221
+ await unlockTaskRequest(taskId, taskName)(dispatch);
222
+
223
+ expect(dispatch).toHaveBeenCalledTimes(3);
224
+ expect(dispatch.mock.calls[0][0]).toEqual({
225
+ type: TASKS_UNLOCK_REQUEST,
226
+ });
227
+ expect(dispatch.mock.calls[1][0]).toEqual({
228
+ type: TASKS_UNLOCK_FAILURE,
229
+ });
230
+ expect(dispatch.mock.calls[2][0]).toEqual(
231
+ toastAction(
232
+ 'some-name Task execution resources cannot be unlocked at the moment.',
233
+ 'warning'
234
+ )
235
+ );
236
+ });
237
+ });
68
238
  });
@@ -1,69 +1,238 @@
1
- import React from 'react';
1
+ import React, { useState, useEffect } from 'react';
2
2
  import PropTypes from 'prop-types';
3
- import { Alert } from '@patternfly/react-core';
3
+ import classNames from 'classnames';
4
+ import {
5
+ Alert,
6
+ AlertVariant,
7
+ CodeBlock,
8
+ CodeBlockCode,
9
+ EmptyState,
10
+ EmptyStateBody,
11
+ EmptyStateHeader,
12
+ Icon,
13
+ EmptyStateVariant,
14
+ Flex,
15
+ FlexItem,
16
+ Grid,
17
+ GridItem,
18
+ Split,
19
+ SplitItem,
20
+ Stack,
21
+ StackItem,
22
+ Tab,
23
+ Tabs,
24
+ TabTitleIcon,
25
+ TabTitleText,
26
+ } from '@patternfly/react-core';
27
+ import {
28
+ CheckCircleIcon,
29
+ ExclamationCircleIcon,
30
+ } from '@patternfly/react-icons';
4
31
  import { translate as __ } from 'foremanReact/common/I18n';
5
32
 
6
- const Errors = ({ ...props }) => {
7
- const { failedSteps, executionPlan } = props;
8
- if (!executionPlan)
33
+ import './Errors.scss';
34
+
35
+ const isStoppedStep = step =>
36
+ ['skipped', 'skipping'].includes(String(step.state ?? ''));
37
+
38
+ const STEP_SUMMARY_MAX_LENGTH = 120;
39
+
40
+ const getStepSummary = step =>
41
+ step.error?.message || step.action_class || __('Unknown error');
42
+
43
+ const truncateStepSummary = summary => {
44
+ if (summary.length <= STEP_SUMMARY_MAX_LENGTH) {
45
+ return summary;
46
+ }
47
+
48
+ return `${summary.slice(0, STEP_SUMMARY_MAX_LENGTH - 3)}...`;
49
+ };
50
+
51
+ const getStepStatus = step => (isStoppedStep(step) ? 'warning' : 'danger');
52
+
53
+ const ErrorTabTitle = ({ step }) => {
54
+ const status = getStepStatus(step);
55
+
56
+ return (
57
+ <>
58
+ <TabTitleIcon>
59
+ <Icon status={status}>
60
+ <ExclamationCircleIcon />
61
+ </Icon>
62
+ </TabTitleIcon>
63
+ <TabTitleText
64
+ className={classNames(
65
+ 'task-errors-tab-title',
66
+ `task-errors-tab-title--${status}`
67
+ )}
68
+ >
69
+ {truncateStepSummary(getStepSummary(step))}
70
+ </TabTitleText>
71
+ </>
72
+ );
73
+ };
74
+
75
+ ErrorTabTitle.propTypes = {
76
+ step: PropTypes.shape({
77
+ action_class: PropTypes.string,
78
+ state: PropTypes.string,
79
+ error: PropTypes.shape({
80
+ message: PropTypes.string,
81
+ }),
82
+ }).isRequired,
83
+ };
84
+
85
+ const ErrorDetailSection = ({ label, children }) => (
86
+ <StackItem className="task-errors-detail-section">
87
+ <Flex
88
+ direction={{ default: 'column' }}
89
+ spaceItems={{ default: 'spaceItemsXs' }}
90
+ >
91
+ <FlexItem className="task-errors-detail-label">
92
+ <strong>{label}</strong>
93
+ </FlexItem>
94
+ <FlexItem>
95
+ <CodeBlock className="task-errors-codeblock">
96
+ <CodeBlockCode className="task-errors-codeblock-code">
97
+ {children}
98
+ </CodeBlockCode>
99
+ </CodeBlock>
100
+ </FlexItem>
101
+ </Flex>
102
+ </StackItem>
103
+ );
104
+
105
+ ErrorDetailSection.propTypes = {
106
+ label: PropTypes.node.isRequired,
107
+ children: PropTypes.node.isRequired,
108
+ };
109
+
110
+ const ErrorDetailsPane = ({ step }) => {
111
+ if (!step) {
112
+ return null;
113
+ }
114
+
115
+ return (
116
+ <Stack>
117
+ {step.error && (
118
+ <>
119
+ <ErrorDetailSection label={`${__('Exception')}:`}>
120
+ {step.error.exception_class}: {step.error.message}
121
+ </ErrorDetailSection>
122
+ <ErrorDetailSection label={__('Backtrace')}>
123
+ {(step.error.backtrace || []).join('\n')}
124
+ </ErrorDetailSection>
125
+ </>
126
+ )}
127
+ <ErrorDetailSection label={__('Input')}>{step.input}</ErrorDetailSection>
128
+ <ErrorDetailSection label={__('Output')}>
129
+ {step.output}
130
+ </ErrorDetailSection>
131
+ </Stack>
132
+ );
133
+ };
134
+
135
+ ErrorDetailsPane.propTypes = {
136
+ step: PropTypes.shape({
137
+ input: PropTypes.node,
138
+ output: PropTypes.node,
139
+ error: PropTypes.shape({
140
+ exception_class: PropTypes.string,
141
+ message: PropTypes.string,
142
+ backtrace: PropTypes.array,
143
+ }),
144
+ }),
145
+ };
146
+
147
+ ErrorDetailsPane.defaultProps = {
148
+ step: null,
149
+ };
150
+
151
+ const Errors = ({ executionPlan, failedSteps }) => {
152
+ const [selectedIndex, setSelectedIndex] = useState(0);
153
+
154
+ useEffect(() => {
155
+ setSelectedIndex(idx => {
156
+ if (idx >= failedSteps.length) {
157
+ return Math.max(0, failedSteps.length - 1);
158
+ }
159
+
160
+ return idx;
161
+ });
162
+ }, [failedSteps.length]);
163
+
164
+ if (!executionPlan) {
9
165
  return (
10
166
  <Alert
11
- variant="danger"
12
- isInline
167
+ title={__('Execution plan data not available ')}
168
+ variant={AlertVariant.danger}
13
169
  ouiaId="task-errors-plan-missing"
14
- title={__('Execution plan unavailable')}
15
- >
16
- {__('Execution plan data not available ')}
17
- </Alert>
170
+ />
18
171
  );
19
- if (!failedSteps.length)
172
+ }
173
+
174
+ if (!failedSteps.length) {
20
175
  return (
21
- <Alert
22
- variant="success"
23
- isInline
24
- ouiaId="task-errors-none"
25
- title={__('No errors')}
26
- />
176
+ <Grid>
177
+ <GridItem span={12}>
178
+ <Flex
179
+ direction={{ default: 'column' }}
180
+ alignItems={{ default: 'alignItemsCenter' }}
181
+ justifyContent={{ default: 'justifyContentCenter' }}
182
+ fullWidth={{ default: 'fullWidth' }}
183
+ >
184
+ <FlexItem>
185
+ <EmptyState variant={EmptyStateVariant.full}>
186
+ <EmptyStateHeader
187
+ titleText={__('No errors found')}
188
+ headingLevel="h2"
189
+ icon={
190
+ <Icon size="xl" status="success">
191
+ <CheckCircleIcon />
192
+ </Icon>
193
+ }
194
+ />
195
+ <EmptyStateBody>
196
+ {__('The task finished with no errors or warnings.')}
197
+ </EmptyStateBody>
198
+ </EmptyState>
199
+ </FlexItem>
200
+ </Flex>
201
+ </GridItem>
202
+ </Grid>
27
203
  );
204
+ }
205
+
206
+ const selectedStep = failedSteps[selectedIndex];
207
+
28
208
  return (
29
- <div>
30
- {failedSteps.map((step, i) => (
31
- <Alert
32
- variant="danger"
33
- isInline
34
- key={i}
35
- ouiaId={`task-error-${i}`}
36
- title={__('Step error')}
209
+ <Split hasGutter>
210
+ <SplitItem className="task-errors-tabs-split">
211
+ <Tabs
212
+ id="task-errors-tabs"
213
+ ouiaId="task-errors-tabs"
214
+ activeKey={selectedIndex}
215
+ className="task-errors-tabs"
216
+ onSelect={(_event, tabIndex) => setSelectedIndex(Number(tabIndex))}
217
+ isVertical
218
+ isBox
219
+ aria-label={__('Failed task errors')}
37
220
  >
38
- <span>{__('Action')}:</span>
39
- <span>
40
- <pre>{step.action_class}</pre>
41
- </span>
42
- <span>{__('Input')}:</span>
43
- <span>
44
- <pre>{step.input}</pre>
45
- </span>
46
- <span>{__('Output')}:</span>
47
- <span>
48
- <pre>{step.output}</pre>
49
- </span>
50
- {step.error && (
51
- <React.Fragment>
52
- <span>{__('Exception')}:</span>
53
- <span>
54
- <pre>
55
- {step.error.exception_class}: {step.error.message}
56
- </pre>
57
- </span>
58
- <span>{__('Backtrace')}:</span>
59
- <span>
60
- <pre>{(step.error.backtrace || []).join('\n')}</pre>
61
- </span>
62
- </React.Fragment>
63
- )}
64
- </Alert>
65
- ))}
66
- </div>
221
+ {failedSteps.map((step, i) => (
222
+ <Tab
223
+ key={`${step.action_class}-${i}`}
224
+ eventKey={i}
225
+ ouiaId={`task-error-${i}`}
226
+ title={<ErrorTabTitle step={step} />}
227
+ aria-label={getStepSummary(step)}
228
+ />
229
+ ))}
230
+ </Tabs>
231
+ </SplitItem>
232
+ <SplitItem isFilled>
233
+ <ErrorDetailsPane step={selectedStep} />
234
+ </SplitItem>
235
+ </Split>
67
236
  );
68
237
  };
69
238
 
@@ -0,0 +1,41 @@
1
+ .task-details-react {
2
+ .task-errors-detail-section {
3
+ margin-top: 1rem;
4
+ margin-left: 1.5rem;
5
+ }
6
+
7
+ .task-errors-detail-label {
8
+ padding-bottom: 0.25rem;
9
+ }
10
+
11
+ .task-errors-tabs-split {
12
+ flex: 0 0 min(33%, 20rem);
13
+ }
14
+
15
+
16
+ .task-errors-codeblock {
17
+ background-color: transparent;
18
+ }
19
+
20
+ .task-errors-codeblock-code {
21
+ margin: -1rem;
22
+ background-color: transparent;
23
+ }
24
+
25
+ .task-errors-tab-title {
26
+ word-break: break-all;
27
+ font-weight: var(--pf-v5-global--FontWeight--bold);
28
+
29
+ &--danger {
30
+ color: var(--pf-v5-global--danger-color--200);
31
+ }
32
+
33
+ &--warning {
34
+ color: var(--pf-v5-global--warning-color--200);
35
+ }
36
+ }
37
+
38
+ .task-errors-tabs {
39
+ --pf-v5-c-tabs--m-vertical--MaxWidth: 100%;
40
+ }
41
+ }