foreman_remote_execution 4.5.5 → 4.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby_ci.yml +7 -0
  3. data/.rubocop_todo.yml +1 -0
  4. data/app/controllers/api/v2/job_invocations_controller.rb +7 -1
  5. data/app/graphql/types/job_invocation.rb +16 -0
  6. data/app/lib/actions/remote_execution/run_host_job.rb +2 -1
  7. data/app/lib/actions/remote_execution/run_hosts_job.rb +57 -3
  8. data/app/mailers/rex_job_mailer.rb +15 -0
  9. data/app/models/job_invocation.rb +4 -0
  10. data/app/models/job_invocation_composer.rb +21 -13
  11. data/app/models/job_template.rb +1 -1
  12. data/app/models/remote_execution_provider.rb +17 -2
  13. data/app/models/rex_mail_notification.rb +13 -0
  14. data/app/models/setting/remote_execution.rb +7 -1
  15. data/app/services/ui_notifications/remote_execution_jobs/base_job_finish.rb +2 -1
  16. data/app/views/dashboard/_latest-jobs.html.erb +21 -0
  17. data/app/views/rex_job_mailer/job_finished.html.erb +24 -0
  18. data/app/views/rex_job_mailer/job_finished.text.erb +9 -0
  19. data/app/views/template_invocations/show.html.erb +2 -1
  20. data/db/seeds.d/50-notification_blueprints.rb +14 -0
  21. data/db/seeds.d/95-mail_notifications.rb +24 -0
  22. data/foreman_remote_execution.gemspec +2 -4
  23. data/lib/foreman_remote_execution/engine.rb +4 -0
  24. data/lib/foreman_remote_execution/version.rb +1 -1
  25. data/package.json +6 -6
  26. data/test/functional/api/v2/job_invocations_controller_test.rb +10 -0
  27. data/test/graphql/queries/job_invocation_query_test.rb +31 -0
  28. data/test/graphql/queries/job_invocations_query_test.rb +35 -0
  29. data/test/unit/actions/run_hosts_job_test.rb +99 -4
  30. data/test/unit/concerns/host_extensions_test.rb +4 -4
  31. data/test/unit/input_template_renderer_test.rb +1 -89
  32. data/test/unit/job_invocation_composer_test.rb +1 -12
  33. data/test/unit/job_invocation_report_template_test.rb +15 -12
  34. data/test/unit/remote_execution_provider_test.rb +34 -0
  35. data/webpack/JobWizard/JobWizard.js +53 -20
  36. data/webpack/JobWizard/JobWizard.scss +33 -4
  37. data/webpack/JobWizard/JobWizardConstants.js +17 -0
  38. data/webpack/JobWizard/__tests__/fixtures.js +8 -0
  39. data/webpack/JobWizard/__tests__/integration.test.js +3 -7
  40. data/webpack/JobWizard/steps/AdvancedFields/AdvancedFields.js +16 -5
  41. data/webpack/JobWizard/steps/AdvancedFields/Fields.js +48 -1
  42. data/webpack/JobWizard/steps/AdvancedFields/__tests__/AdvancedFields.test.js +29 -14
  43. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js +4 -2
  44. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.test.js +3 -2
  45. data/webpack/JobWizard/steps/HostsAndInputs/SelectedChips.js +25 -0
  46. data/webpack/JobWizard/steps/HostsAndInputs/TemplateInputs.js +23 -0
  47. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/SelectedChips.test.js +37 -0
  48. data/webpack/JobWizard/steps/HostsAndInputs/__tests__/TemplateInputs.test.js +50 -0
  49. data/webpack/JobWizard/steps/HostsAndInputs/index.js +66 -0
  50. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +24 -21
  51. data/webpack/JobWizard/steps/Schedule/StartEndDates.js +36 -21
  52. data/webpack/JobWizard/steps/Schedule/__tests__/Schedule.test.js +155 -0
  53. data/webpack/JobWizard/steps/Schedule/__tests__/StartEndDates.test.js +9 -8
  54. data/webpack/JobWizard/steps/Schedule/index.js +89 -28
  55. data/webpack/JobWizard/steps/form/DateTimePicker.js +93 -0
  56. data/webpack/JobWizard/steps/form/Formatter.js +10 -9
  57. data/webpack/JobWizard/steps/form/NumberInput.js +2 -0
  58. data/webpack/JobWizard/steps/form/WizardTitle.js +14 -0
  59. data/webpack/react_app/components/RecentJobsCard/JobStatusIcon.js +43 -0
  60. data/webpack/react_app/components/RecentJobsCard/RecentJobsCard.js +73 -66
  61. data/webpack/react_app/components/RecentJobsCard/RecentJobsTable.js +98 -0
  62. data/webpack/react_app/components/RecentJobsCard/constants.js +11 -0
  63. data/webpack/react_app/components/RecentJobsCard/styles.scss +11 -0
  64. data/webpack/react_app/extend/fillRecentJobsCard.js +1 -1
  65. metadata +26 -19
  66. data/webpack/react_app/components/RecentJobsCard/styles.css +0 -15
@@ -0,0 +1,155 @@
1
+ import React from 'react';
2
+ import { Provider } from 'react-redux';
3
+ import configureMockStore from 'redux-mock-store';
4
+ import { fireEvent, screen, render, act } from '@testing-library/react';
5
+ import * as api from 'foremanReact/redux/API';
6
+ import { JobWizard } from '../../../JobWizard';
7
+ import * as selectors from '../../../JobWizardSelectors';
8
+ import { jobTemplate, jobTemplateResponse } from '../../../__tests__/fixtures';
9
+
10
+ const lodash = require('lodash');
11
+
12
+ lodash.debounce = fn => fn;
13
+ jest.spyOn(api, 'get');
14
+ jest.spyOn(selectors, 'selectJobTemplate');
15
+ jest.spyOn(selectors, 'selectJobTemplates');
16
+ jest.spyOn(selectors, 'selectJobCategories');
17
+
18
+ const jobCategories = ['Ansible Commands', 'Puppet', 'Services'];
19
+
20
+ selectors.selectJobCategories.mockImplementation(() => jobCategories);
21
+
22
+ selectors.selectJobTemplates.mockImplementation(() => [
23
+ jobTemplate,
24
+ { ...jobTemplate, id: 2, name: 'template2' },
25
+ ]);
26
+ selectors.selectJobTemplate.mockImplementation(() => jobTemplateResponse);
27
+ api.get.mockImplementation(({ handleSuccess, ...action }) => {
28
+ if (action.key === 'JOB_CATEGORIES') {
29
+ handleSuccess && handleSuccess({ data: { job_categories: jobCategories } });
30
+ } else if (action.key === 'JOB_TEMPLATE') {
31
+ handleSuccess &&
32
+ handleSuccess({
33
+ data: jobTemplateResponse,
34
+ });
35
+ } else if (action.key === 'JOB_TEMPLATES') {
36
+ handleSuccess &&
37
+ handleSuccess({
38
+ data: { results: [jobTemplateResponse.job_template] },
39
+ });
40
+ }
41
+ return { type: 'get', ...action };
42
+ });
43
+
44
+ const mockStore = configureMockStore([]);
45
+ const store = mockStore({});
46
+ jest.useFakeTimers();
47
+
48
+ describe('Schedule', () => {
49
+ it('should save date time between steps ', async () => {
50
+ render(
51
+ <Provider store={store}>
52
+ <JobWizard />
53
+ </Provider>
54
+ );
55
+ await act(async () => {
56
+ fireEvent.click(screen.getByText('Schedule'));
57
+ });
58
+ const newStartDate = '2020/03/12';
59
+ const newStartTime = '12:03';
60
+ const newEndsDate = '2030/03/12';
61
+ const newEndsTime = '17:34';
62
+ const [startsDateField, endsDateField] = screen.getAllByPlaceholderText(
63
+ 'yyyy/mm/dd'
64
+ );
65
+ const [startsTimeField, endsTimeField] = screen.getAllByPlaceholderText(
66
+ 'hh:mm'
67
+ );
68
+ await act(async () => {
69
+ await fireEvent.change(startsDateField, {
70
+ target: { value: newStartDate },
71
+ });
72
+ await fireEvent.change(startsTimeField, {
73
+ target: { value: newStartTime },
74
+ });
75
+ await fireEvent.change(endsDateField, { target: { value: newEndsDate } });
76
+ await fireEvent.change(endsTimeField, { target: { value: newEndsTime } });
77
+ jest.runAllTimers(); // to handle pf4 date picket popover useTimer
78
+ });
79
+ await act(async () => {
80
+ fireEvent.click(screen.getByText('Category and Template'));
81
+ });
82
+ expect(screen.getAllByText('Category and Template')).toHaveLength(3);
83
+
84
+ await act(async () => {
85
+ fireEvent.click(screen.getByText('Schedule'));
86
+ jest.runAllTimers();
87
+ });
88
+ expect(startsDateField.value).toBe(newStartDate);
89
+ expect(startsTimeField.value).toBe(newStartTime);
90
+ expect(endsDateField.value).toBe(newEndsDate);
91
+ expect(endsTimeField.value).toBe(newEndsTime);
92
+ });
93
+ it('should remove start date time on execute now', async () => {
94
+ render(
95
+ <Provider store={store}>
96
+ <JobWizard />
97
+ </Provider>
98
+ );
99
+ await act(async () => {
100
+ fireEvent.click(screen.getByText('Schedule'));
101
+ });
102
+ const executeNow = screen.getByLabelText('Execute now');
103
+ const executeFuture = screen.getByLabelText(
104
+ 'Schedule for future execution'
105
+ );
106
+ expect(executeNow.checked).toBeTruthy();
107
+ const newStartDate = '2020/03/12';
108
+ const newStartTime = '12:03';
109
+ const [startsDateField] = screen.getAllByPlaceholderText('yyyy/mm/dd');
110
+ const [startsTimeField] = screen.getAllByPlaceholderText('hh:mm');
111
+ await act(async () => {
112
+ await fireEvent.change(startsDateField, {
113
+ target: { value: newStartDate },
114
+ });
115
+ await fireEvent.change(startsTimeField, {
116
+ target: { value: newStartTime },
117
+ });
118
+ await jest.runOnlyPendingTimers();
119
+ });
120
+ expect(startsDateField.value).toBe(newStartDate);
121
+ expect(startsTimeField.value).toBe(newStartTime);
122
+ expect(executeFuture.checked).toBeTruthy();
123
+ await act(async () => {
124
+ await fireEvent.click(executeNow);
125
+ });
126
+ expect(executeNow.checked).toBeTruthy();
127
+ expect(startsDateField.value).toBe('');
128
+ expect(startsTimeField.value).toBe('');
129
+ });
130
+
131
+ it('should disable end date on never ends', async () => {
132
+ render(
133
+ <Provider store={store}>
134
+ <JobWizard />
135
+ </Provider>
136
+ );
137
+ await act(async () => {
138
+ await fireEvent.click(screen.getByText('Schedule'));
139
+ jest.runAllTimers();
140
+ });
141
+ const neverEnds = screen.getByLabelText('Never ends');
142
+ expect(neverEnds.checked).toBeFalsy();
143
+
144
+ const [, endsDateField] = screen.getAllByPlaceholderText('yyyy/mm/dd');
145
+ const [, endsTimeField] = screen.getAllByPlaceholderText('hh:mm');
146
+ expect(endsDateField.disabled).toBeFalsy();
147
+ expect(endsTimeField.disabled).toBeFalsy();
148
+ await act(async () => {
149
+ fireEvent.click(neverEnds);
150
+ });
151
+ expect(neverEnds.checked).toBeTruthy();
152
+ expect(endsDateField.disabled).toBeTruthy();
153
+ expect(endsTimeField.disabled).toBeTruthy();
154
+ });
155
+ });
@@ -1,22 +1,23 @@
1
1
  import React from 'react';
2
- import { render, fireEvent, screen } from '@testing-library/react';
2
+ import { render, fireEvent, screen, act } from '@testing-library/react';
3
3
  import { StartEndDates } from '../StartEndDates';
4
4
 
5
5
  const setEnds = jest.fn();
6
+ const setIsNeverEnds = jest.fn();
6
7
  const props = {
7
8
  starts: '',
8
9
  setStarts: jest.fn(),
9
10
  ends: 'some-end-date',
10
11
  setEnds,
12
+ setIsNeverEnds,
13
+ isNeverEnds: false,
11
14
  };
12
15
 
13
16
  describe('StartEndDates', () => {
14
- it('never ends', () => {
15
- render(<StartEndDates {...props} />);
16
- const neverEnds = screen.getByLabelText('Never ends', {
17
- selector: 'input',
18
- });
19
- fireEvent.click(neverEnds);
20
- expect(setEnds).toBeCalledWith('');
17
+ it('never ends', async () => {
18
+ await act(async () => render(<StartEndDates {...props} />));
19
+ const neverEnds = screen.getByRole('checkbox', { name: 'Never ends' });
20
+ await act(async () => fireEvent.click(neverEnds));
21
+ expect(setIsNeverEnds).toBeCalledWith(true);
21
22
  });
22
23
  });
@@ -1,41 +1,102 @@
1
- import React, { useState } from 'react';
2
- import { Title, Button, Form } from '@patternfly/react-core';
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { Button, Form } from '@patternfly/react-core';
3
4
  import { translate as __ } from 'foremanReact/common/I18n';
4
5
  import { ScheduleType } from './ScheduleType';
5
6
  import { RepeatOn } from './RepeatOn';
6
7
  import { QueryType } from './QueryType';
7
8
  import { StartEndDates } from './StartEndDates';
8
- import { repeatTypes } from '../../JobWizardConstants';
9
+ import { WIZARD_TITLES } from '../../JobWizardConstants';
10
+ import { WizardTitle } from '../form/WizardTitle';
9
11
 
10
- const Schedule = () => {
11
- const [repeatType, setRepeatType] = useState(repeatTypes.noRepeat);
12
- const [repeatAmount, setRepeatAmount] = useState('');
13
- const [starts, setStarts] = useState('');
14
- const [ends, setEnds] = useState('');
12
+ const Schedule = ({ scheduleValue, setScheduleValue }) => {
13
+ const { repeatType, repeatAmount, starts, ends, isNeverEnds } = scheduleValue;
15
14
 
16
15
  return (
17
- <Form className="schedule-tab">
18
- <Title headingLevel="h2">{__('Schedule')}</Title>
19
- <ScheduleType />
16
+ <>
17
+ <WizardTitle title={WIZARD_TITLES.schedule} />
18
+ <Form className="schedule-tab">
19
+ <ScheduleType
20
+ isFuture={scheduleValue.isFuture}
21
+ setIsFuture={newValue => {
22
+ if (!newValue) {
23
+ // if schedule type is execute now
24
+ setScheduleValue(current => ({
25
+ ...current,
26
+ starts: '',
27
+ }));
28
+ }
29
+ setScheduleValue(current => ({
30
+ ...current,
31
+ isFuture: newValue,
32
+ }));
33
+ }}
34
+ />
20
35
 
21
- <RepeatOn
22
- repeatType={repeatType}
23
- setRepeatType={setRepeatType}
24
- repeatAmount={repeatAmount}
25
- setRepeatAmount={setRepeatAmount}
26
- />
27
- <StartEndDates
28
- starts={starts}
29
- setStarts={setStarts}
30
- ends={ends}
31
- setEnds={setEnds}
32
- />
33
- <Button variant="link" className="advanced-scheduling-button" isInline>
34
- {__('Advanced scheduling')}
35
- </Button>
36
- <QueryType />
37
- </Form>
36
+ <RepeatOn
37
+ repeatType={repeatType}
38
+ setRepeatType={newValue => {
39
+ setScheduleValue(current => ({
40
+ ...current,
41
+ repeatType: newValue,
42
+ }));
43
+ }}
44
+ repeatAmount={repeatAmount}
45
+ setRepeatAmount={newValue => {
46
+ setScheduleValue(current => ({
47
+ ...current,
48
+ repeatAmount: newValue,
49
+ }));
50
+ }}
51
+ />
52
+ <StartEndDates
53
+ starts={starts}
54
+ setStarts={newValue => {
55
+ if (!scheduleValue.isFuture) {
56
+ setScheduleValue(current => ({
57
+ ...current,
58
+ isFuture: true,
59
+ }));
60
+ }
61
+ setScheduleValue(current => ({
62
+ ...current,
63
+ starts: newValue,
64
+ }));
65
+ }}
66
+ ends={ends}
67
+ setEnds={newValue => {
68
+ setScheduleValue(current => ({
69
+ ...current,
70
+ ends: newValue,
71
+ }));
72
+ }}
73
+ isNeverEnds={isNeverEnds}
74
+ setIsNeverEnds={newValue => {
75
+ setScheduleValue(current => ({
76
+ ...current,
77
+ isNeverEnds: newValue,
78
+ }));
79
+ }}
80
+ />
81
+ <Button variant="link" className="advanced-scheduling-button" isInline>
82
+ {__('Advanced scheduling')}
83
+ </Button>
84
+ <QueryType />
85
+ </Form>
86
+ </>
38
87
  );
39
88
  };
40
89
 
90
+ Schedule.propTypes = {
91
+ scheduleValue: PropTypes.shape({
92
+ repeatType: PropTypes.string.isRequired,
93
+ repeatAmount: PropTypes.string,
94
+ starts: PropTypes.string,
95
+ ends: PropTypes.string,
96
+ isFuture: PropTypes.bool,
97
+ isNeverEnds: PropTypes.bool,
98
+ }).isRequired,
99
+ setScheduleValue: PropTypes.func.isRequired,
100
+ };
101
+
41
102
  export default Schedule;
@@ -0,0 +1,93 @@
1
+ import React from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { DatePicker, TimePicker } from '@patternfly/react-core';
4
+ import { debounce } from 'lodash';
5
+ import { translate as __ } from 'foremanReact/common/I18n';
6
+
7
+ export const DateTimePicker = ({ dateTime, setDateTime, isDisabled }) => {
8
+ const dateFormat = date =>
9
+ `${date.getFullYear()}/${(date.getMonth() + 1)
10
+ .toString()
11
+ .padStart(2, '0')}/${date
12
+ .getDate()
13
+ .toString()
14
+ .padStart(2, '0')}`;
15
+
16
+ const dateObject = dateTime ? new Date(dateTime) : new Date();
17
+ const formattedDate = dateTime ? dateFormat(dateObject) : '';
18
+ const dateParse = date =>
19
+ new Date(`${date} ${dateObject.getHours()}:${dateObject.getMinutes()}`);
20
+
21
+ const isValidDate = date => date && !Number.isNaN(date.getTime());
22
+
23
+ const isValidTime = time => {
24
+ if (!time) return false;
25
+ const split = time.split(':');
26
+ if (!(split[0].length === 2 && split[1].length === 2)) return false;
27
+ if (isValidDate(new Date(`${formattedDate} ${time}`))) return true;
28
+ if (!formattedDate.length && isValidDate(new Date(`01/01/2020 ${time}`))) {
29
+ const today = new Date();
30
+ today.setHours(split[0]);
31
+ today.setMinutes(split[1]);
32
+ setDateTime(today.toString());
33
+ }
34
+ return false;
35
+ };
36
+
37
+ const onDateChange = newDate => {
38
+ const parsedNewDate = new Date(newDate);
39
+
40
+ if (isValidDate(parsedNewDate)) {
41
+ parsedNewDate.setHours(dateObject.getHours());
42
+ parsedNewDate.setMinutes(dateObject.getMinutes());
43
+ setDateTime(parsedNewDate.toString());
44
+ }
45
+ };
46
+
47
+ const onTimeChange = newTime => {
48
+ if (isValidTime(newTime)) {
49
+ const parsedNewTime = new Date(`${formattedDate} ${newTime}`);
50
+ setDateTime(parsedNewTime.toString());
51
+ }
52
+ };
53
+ return (
54
+ <>
55
+ <DatePicker
56
+ value={formattedDate}
57
+ placeholder="yyyy/mm/dd"
58
+ onChange={debounce(onDateChange, 1000, {
59
+ leading: false,
60
+ trailing: true,
61
+ })}
62
+ dateFormat={dateFormat}
63
+ dateParse={dateParse}
64
+ isDisabled={isDisabled}
65
+ invalidFormatText={__('Invalid date')}
66
+ />
67
+ <TimePicker
68
+ className="time-picker"
69
+ time={dateTime ? dateObject.toString() : ''}
70
+ inputProps={dateTime ? {} : { value: '' }}
71
+ placeholder="hh:mm"
72
+ onChange={debounce(onTimeChange, 1000, {
73
+ leading: false,
74
+ trailing: true,
75
+ })}
76
+ is24Hour
77
+ isDisabled={isDisabled}
78
+ invalidFormatErrorMessage={__('Invalid time format')}
79
+ menuAppendTo={() => document.body}
80
+ />
81
+ </>
82
+ );
83
+ };
84
+
85
+ DateTimePicker.propTypes = {
86
+ dateTime: PropTypes.string,
87
+ setDateTime: PropTypes.func.isRequired,
88
+ isDisabled: PropTypes.bool,
89
+ };
90
+ DateTimePicker.defaultProps = {
91
+ dateTime: null,
92
+ isDisabled: false,
93
+ };
@@ -22,11 +22,12 @@ const TemplateSearchField = ({
22
22
  setValue({ ...values, [name]: searchQuery });
23
23
  // eslint-disable-next-line react-hooks/exhaustive-deps
24
24
  }, [searchQuery]);
25
+ const id = name.replace(/ /g, '-');
25
26
  return (
26
27
  <FormGroup
27
28
  label={name}
28
29
  labelIcon={helpLabel(labelText, name)}
29
- fieldId={name}
30
+ fieldId={id}
30
31
  isRequired={required}
31
32
  className="foreman-search-field"
32
33
  >
@@ -54,16 +55,16 @@ export const formatter = (input, values, setValue) => {
54
55
  const { name, required, hidden_value: hidden } = input;
55
56
  const labelText = input.description;
56
57
  const value = values[name];
57
-
58
+ const id = name.replace(/ /g, '-');
58
59
  if (isSelectType) {
59
60
  const options = input.options.split(/\r?\n/).map(option => option.trim());
60
61
  return (
61
62
  <SelectField
62
63
  aria-label={name}
63
- key={name}
64
+ key={id}
64
65
  isRequired={required}
65
66
  label={name}
66
- fieldId={name}
67
+ fieldId={id}
67
68
  options={options}
68
69
  labelIcon={helpLabel(labelText, name)}
69
70
  value={value}
@@ -77,7 +78,7 @@ export const formatter = (input, values, setValue) => {
77
78
  key={name}
78
79
  label={name}
79
80
  labelIcon={helpLabel(labelText, name)}
80
- fieldId={name}
81
+ fieldId={id}
81
82
  isRequired={required}
82
83
  >
83
84
  <TextArea
@@ -85,7 +86,7 @@ export const formatter = (input, values, setValue) => {
85
86
  className={hidden ? 'masked-input' : null}
86
87
  required={required}
87
88
  rows={2}
88
- id={name}
89
+ id={id}
89
90
  value={value}
90
91
  onChange={newValue => setValue({ ...values, [name]: newValue })}
91
92
  />
@@ -98,7 +99,7 @@ export const formatter = (input, values, setValue) => {
98
99
  key={name}
99
100
  label={name}
100
101
  labelIcon={helpLabel(labelText, name)}
101
- fieldId={name}
102
+ fieldId={id}
102
103
  isRequired={required}
103
104
  >
104
105
  <TextInput
@@ -106,7 +107,7 @@ export const formatter = (input, values, setValue) => {
106
107
  placeholder="YYYY-mm-dd HH:MM"
107
108
  className={hidden ? 'masked-input' : null}
108
109
  required={required}
109
- id={name}
110
+ id={id}
110
111
  type="text"
111
112
  value={value}
112
113
  onChange={newValue => setValue({ ...values, [name]: newValue })}
@@ -119,7 +120,7 @@ export const formatter = (input, values, setValue) => {
119
120
  // TODO: get text from redux autocomplete
120
121
  return (
121
122
  <TemplateSearchField
122
- key={name}
123
+ key={id}
123
124
  name={name}
124
125
  defaultValue={value}
125
126
  controller={controller}