foreman_remote_execution 16.0.5 → 16.1.0

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: f0af6a4d6d568edd8bf5d878940d19f72d3f2b2ad2c67e767cd7764e2287ba3f
4
- data.tar.gz: 5b7f5b64a90c38b6b65df711be7bf93c586c73400386c96c6ae280285b4a9349
3
+ metadata.gz: 8150a0940b1cf9352e24f99f5083c36fb7bf42aefdfd99406eaeb191c109ec55
4
+ data.tar.gz: 673400df79bdee44cb8eccbcb72ce654963dca2f1e94f015d8ffcbdcb625a710
5
5
  SHA512:
6
- metadata.gz: 408d5714fba95e7213fa42c2510021231cd2d5c61b7ecc502a0d76c96ca80f97758e03928da5a1c1acc9547fe4fcbfbe2f836991954b78a09cbe7b6ef0644e95
7
- data.tar.gz: c0468bdebd334affe636a1aa34dff3689707084a0d0f141b05c0a09428f6700aa48b1a43b2e9e61262d8bd8968ab4c22b3d1ab68ad48cf8527b930989fac8994
6
+ metadata.gz: f812d26367d5f24535531150d58aa224967f47db0651b6de2bb267ba5d037608665ec380e5b3d96003796a81f3b9f4c376d2e7de4365bdca4719ea012828a98b
7
+ data.tar.gz: 102ef1de3d1f874fb70ac04cde6fb1f91ecb4980198c25d29afb23a9e3f3e28a2b98ec5e1bbb69a7271e3639a31277e4dd1828848c322fc0989e69c4e263c17a
@@ -134,16 +134,43 @@ module Api
134
134
  render :json => host_output(@nested_obj, @host, :raw => true)
135
135
  end
136
136
 
137
- api :POST, '/job_invocations/:id/cancel', N_('Cancel job invocation')
137
+ api :POST, '/job_invocations/:id/cancel', N_('Cancel job invocation or matching tasks only')
138
138
  param :id, :identifier, :required => true
139
139
  param :force, :bool
140
+ param :search, String, :desc => N_('Search query to cancel tasks only on matching hosts. If not provided, the whole job invocation will be cancelled.')
140
141
  def cancel
141
- if @job_invocation.task.cancellable?
142
- result = @job_invocation.cancel(params.fetch('force', false))
143
- render :json => { :cancelled => result, :id => @job_invocation.id }
142
+ force = params.fetch('force', false)
143
+ search = params[:search]
144
+
145
+ if search.present?
146
+ begin
147
+ hosts_scope = @job_invocation.targeting.hosts.authorized(:view_hosts, Host)
148
+ matching_hosts = hosts_scope.search_for(search)
149
+ tasks_to_process = @job_invocation.template_invocations
150
+ .where(host_id: matching_hosts.select(:id))
151
+ .includes(:run_host_job_task)
152
+
153
+ cancelled, skipped = tasks_to_process.partition { |ti| ti.run_host_job_task&.cancellable? }
154
+ cancelled.each do |ti|
155
+ if force
156
+ ti.run_host_job_task.abort
157
+ else
158
+ ti.run_host_job_task.cancel
159
+ end
160
+ end
161
+ render json: {
162
+ total: tasks_to_process.size,
163
+ cancelled: cancelled.map(&:run_host_job_task_id),
164
+ skipped: skipped.map(&:run_host_job_task_id),
165
+ }
166
+ rescue StandardError => e
167
+ render json: { error: { message: "Failed to cancel tasks on hosts: #{e.message}" } }, status: :unprocessable_entity
168
+ end
169
+ elsif @job_invocation.task.cancellable?
170
+ result = @job_invocation.cancel(force)
171
+ render json: { cancelled: result, id: @job_invocation.id }
144
172
  else
145
- render :json => { :message => _('The job could not be cancelled.') },
146
- :status => :unprocessable_entity
173
+ render json: { message: _('The job could not be cancelled.') }, status: :unprocessable_entity
147
174
  end
148
175
  end
149
176
 
@@ -99,7 +99,7 @@ module RemoteExecutionHelper
99
99
  :disabled => !task.cancellable?,
100
100
  :method => :post)
101
101
  end
102
- buttons << link_to(_('New UI'), new_job_invocation_detail_path(:id => job_invocation.id),
102
+ buttons << link_to(_('New UI'), job_invocation_path(:id => job_invocation.id),
103
103
  class: 'btn btn-default',
104
104
  title: _('Switch to the new job invocation detail UI'))
105
105
 
data/config/routes.rb CHANGED
@@ -22,11 +22,12 @@ Rails.application.routes.draw do
22
22
  match 'job_invocations/:id/rerun', to: 'react#index', :via => [:get], as: 'rerun_job_invocation'
23
23
  match 'old/job_invocations/new', to: 'job_invocations#new', via: [:get], as: 'form_new_job_invocation'
24
24
  match 'old/job_invocations/:id/rerun', to: 'job_invocations#rerun', via: [:get, :post], as: 'form_rerun_job_invocation'
25
- match 'experimental/job_invocations_detail/:id', to: 'react#index', :via => [:get], as: 'new_job_invocation_detail'
25
+ get 'job_invocations/:id', to: 'react#index', as: 'job_invocation'
26
+ get 'legacy/job_invocations/:id', to: 'job_invocations#show', as: 'legacy_job_invocation'
26
27
  match 'job_invocations_detail/:id/host_invocation/:host_id', to: 'react#index', :via => [:get], as: 'new_job_invocation_detail_by_host'
27
28
  get 'show_template_invocation_by_host/:host_id/job_invocation/:id', to: 'template_invocations#show_template_invocation_by_host'
28
29
 
29
- resources :job_invocations, :only => [:create, :show, :index] do
30
+ resources :job_invocations, :only => [:create, :index] do
30
31
  collection do
31
32
  get 'preview_job_invocations_per_host'
32
33
  post 'refresh'
@@ -195,12 +195,6 @@ Foreman::Plugin.register :foreman_remote_execution do
195
195
  parent: :monitor_menu,
196
196
  after: :audits
197
197
 
198
- menu :labs_menu, :job_invocations_detail,
199
- url_hash: { controller: :job_invocations, action: :show },
200
- caption: N_('Job invocations detail'),
201
- parent: :lab_features_menu,
202
- url: '/experimental/job_invocations_detail/1'
203
-
204
198
  register_custom_status HostStatus::ExecutionStatus
205
199
  # add dashboard widget
206
200
  # widget 'foreman_remote_execution_widget', name: N_('Foreman plugin template widget'), sizex: 4, sizey: 1
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '16.0.5'.freeze
2
+ VERSION = '16.1.0'.freeze
3
3
  end
@@ -1,6 +1,7 @@
1
- /* eslint-disable camelcase */
1
+ /* eslint-disable max-lines */
2
2
  import {
3
3
  Button,
4
+ Divider,
4
5
  Dropdown,
5
6
  DropdownItem,
6
7
  DropdownList,
@@ -11,20 +12,97 @@ import {
11
12
  EllipsisVIcon,
12
13
  OutlinedWindowRestoreIcon,
13
14
  } from '@patternfly/react-icons';
14
- import { sprintf, translate as __ } from 'foremanReact/common/I18n';
15
+ import axios from 'axios';
15
16
  import { foremanUrl } from 'foremanReact/common/helpers';
16
17
  import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
18
+ import { translate as __, sprintf } from 'foremanReact/common/I18n';
19
+ import { addToast } from 'foremanReact/components/ToastsList';
17
20
  import PropTypes from 'prop-types';
18
21
  import React, { useState } from 'react';
19
- import { useSelector } from 'react-redux';
22
+ import { useDispatch, useSelector } from 'react-redux';
23
+
20
24
  import {
21
- templateInvocationPageUrl,
22
- MAX_HOSTS_API_SIZE,
23
25
  DIRECT_OPEN_HOST_LIMIT,
26
+ MAX_HOSTS_API_SIZE,
27
+ templateInvocationPageUrl,
24
28
  } from './JobInvocationConstants';
25
- import { selectHasPermission, selectItems } from './JobInvocationSelectors';
29
+ import {
30
+ selectHasPermission,
31
+ selectItems,
32
+ selectTaskCancelable,
33
+ } from './JobInvocationSelectors';
26
34
  import OpenAllInvocationsModal, { PopupAlert } from './OpenAllInvocationsModal';
27
35
 
36
+ /* eslint-disable camelcase */
37
+ const ActionsKebab = ({
38
+ selectedIds,
39
+ failedCount,
40
+ isTaskCancelable,
41
+ hasCancelPermission,
42
+ handleTaskAction,
43
+ handleOpenHosts,
44
+ isDropdownOpen,
45
+ setIsDropdownOpen,
46
+ }) => {
47
+ const dropdownItems = [
48
+ <DropdownItem
49
+ ouiaId="cancel-host-dropdown-item"
50
+ onClick={() => handleTaskAction('cancel')}
51
+ key="cancel"
52
+ component="button"
53
+ isDisabled={
54
+ selectedIds.length === 0 || !isTaskCancelable || !hasCancelPermission
55
+ }
56
+ >
57
+ {__('Cancel selected')}
58
+ </DropdownItem>,
59
+ <DropdownItem
60
+ ouiaId="abort-host-dropdown-item"
61
+ onClick={() => handleTaskAction('abort')}
62
+ key="abort"
63
+ component="button"
64
+ isDisabled={
65
+ selectedIds.length === 0 || !isTaskCancelable || !hasCancelPermission
66
+ }
67
+ >
68
+ {__('Abort selected')}
69
+ </DropdownItem>,
70
+ <Divider component="li" key="separator" />,
71
+ <DropdownItem
72
+ ouiaId="open-failed-dropdown-item"
73
+ key="open-failed"
74
+ onClick={() => handleOpenHosts('failed')}
75
+ isDisabled={failedCount === 0}
76
+ >
77
+ {sprintf(__('Open all failed runs (%s)'), failedCount)}
78
+ </DropdownItem>,
79
+ ];
80
+
81
+ return (
82
+ <Dropdown
83
+ isOpen={isDropdownOpen}
84
+ onOpenChange={setIsDropdownOpen}
85
+ onSelect={() => setIsDropdownOpen(false)}
86
+ ouiaId="actions-kebab"
87
+ shouldFocusToggleOnSelect
88
+ toggle={toggleRef => (
89
+ <MenuToggle
90
+ aria-label="actions dropdown toggle"
91
+ id="toggle-kebab"
92
+ isExpanded={isDropdownOpen}
93
+ onClick={() => setIsDropdownOpen(prev => !prev)}
94
+ ref={toggleRef}
95
+ variant="plain"
96
+ >
97
+ <EllipsisVIcon />
98
+ </MenuToggle>
99
+ )}
100
+ >
101
+ <DropdownList>{dropdownItems}</DropdownList>
102
+ </Dropdown>
103
+ );
104
+ };
105
+
28
106
  export const CheckboxesActions = ({
29
107
  selectedIds,
30
108
  failedCount,
@@ -35,9 +113,16 @@ export const CheckboxesActions = ({
35
113
  const [isModalOpen, setIsModalOpen] = useState(false);
36
114
  const [showAlert, setShowAlert] = useState(false);
37
115
  const [isOpenFailed, setIsOpenFailed] = useState(false);
38
- const hasPermission = useSelector(
116
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
117
+ const isTaskCancelable = useSelector(selectTaskCancelable);
118
+ const dispatch = useDispatch();
119
+
120
+ const hasCreatePermission = useSelector(
39
121
  selectHasPermission('create_job_invocations')
40
122
  );
123
+ const hasCancelPermission = useSelector(
124
+ selectHasPermission('cancel_job_invocations')
125
+ );
41
126
  const jobSearchQuery = useSelector(selectItems)?.targeting?.search_query;
42
127
  const filterQuery =
43
128
  filter && filter !== 'all_statuses'
@@ -89,43 +174,64 @@ export const CheckboxesActions = ({
89
174
  setIsModalOpen(true);
90
175
  };
91
176
 
92
- const ActionsKebab = () => {
93
- const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
94
-
95
- const dropdownItems = [
96
- <DropdownItem
97
- ouiaId="open-failed-dropdown-item"
98
- key="open-failed"
99
- onClick={() => handleOpenHosts('failed')}
100
- isDisabled={failedCount === 0}
101
- >
102
- {sprintf(__('Open all failed runs (%s)'), failedCount)}
103
- </DropdownItem>,
104
- ];
105
-
106
- return (
107
- <Dropdown
108
- isOpen={isDropdownOpen}
109
- onOpenChange={setIsDropdownOpen}
110
- onSelect={() => setIsDropdownOpen(false)}
111
- ouiaId="actions-kebab"
112
- shouldFocusToggleOnSelect
113
- toggle={toggleRef => (
114
- <MenuToggle
115
- aria-label="actions dropdown toggle"
116
- id="toggle-kebab"
117
- isExpanded={isDropdownOpen}
118
- onClick={() => setIsDropdownOpen(prev => !prev)}
119
- ref={toggleRef}
120
- variant="plain"
121
- >
122
- <EllipsisVIcon />
123
- </MenuToggle>
124
- )}
125
- >
126
- <DropdownList>{dropdownItems}</DropdownList>
127
- </Dropdown>
177
+ const cancelJobTasks = (search, action) => async () => {
178
+ dispatch(
179
+ addToast({
180
+ key: `cancel-job-info`,
181
+ type: 'info',
182
+ message: sprintf(__('Trying to %s the task'), action),
183
+ })
128
184
  );
185
+
186
+ try {
187
+ const response = await axios.post(
188
+ `/api/v2/job_invocations/${jobID}/cancel`,
189
+ {
190
+ search,
191
+ force: action !== 'cancel',
192
+ }
193
+ );
194
+
195
+ const cancelledTasks = response.data?.cancelled;
196
+ const pastTenseAction =
197
+ action === 'cancel' ? __('cancelled') : __('aborted');
198
+
199
+ if (cancelledTasks && cancelledTasks.length > 0) {
200
+ const idList = cancelledTasks.join(', ');
201
+ dispatch(
202
+ addToast({
203
+ key: `success-tasks-cancelled`,
204
+ type: 'success',
205
+ message: sprintf(
206
+ __('%s task(s) successfully %s: %s'),
207
+ cancelledTasks.length,
208
+ pastTenseAction,
209
+ idList
210
+ ),
211
+ })
212
+ );
213
+ } else {
214
+ dispatch(
215
+ addToast({
216
+ key: `warn-no-tasks-cancelled-${Date.now()}`,
217
+ type: 'warning',
218
+ message: sprintf(__('Task(s) were not %s'), pastTenseAction),
219
+ })
220
+ );
221
+ }
222
+ } catch (error) {
223
+ dispatch(
224
+ addToast({
225
+ key: `error-cancelling-tasks`,
226
+ type: 'danger',
227
+ message: error.response?.data?.error || __('An error occurred.'),
228
+ })
229
+ );
230
+ }
231
+ };
232
+
233
+ const handleTaskAction = action => {
234
+ dispatch(cancelJobTasks(combinedQuery, action));
129
235
  };
130
236
 
131
237
  const OpenAllButton = () => (
@@ -153,7 +259,7 @@ export const CheckboxesActions = ({
153
259
  `/job_invocations/${jobID}/rerun?search=(${jobSearchQuery}) AND (${combinedQuery})`
154
260
  )}
155
261
  // eslint-disable-next-line camelcase
156
- isDisabled={selectedIds.length === 0 || !hasPermission}
262
+ isDisabled={selectedIds.length === 0 || !hasCreatePermission}
157
263
  isInline
158
264
  ouiaId="template-invocation-rerun-selected-button"
159
265
  variant="secondary"
@@ -166,7 +272,16 @@ export const CheckboxesActions = ({
166
272
  <>
167
273
  <OpenAllButton />
168
274
  <RerunSelectedButton />
169
- <ActionsKebab />
275
+ <ActionsKebab
276
+ selectedIds={selectedIds}
277
+ failedCount={failedCount}
278
+ isTaskCancelable={isTaskCancelable}
279
+ hasCancelPermission={hasCancelPermission}
280
+ handleTaskAction={handleTaskAction}
281
+ handleOpenHosts={handleOpenHosts}
282
+ isDropdownOpen={isDropdownOpen}
283
+ setIsDropdownOpen={setIsDropdownOpen}
284
+ />
170
285
  {showAlert && <PopupAlert setShowAlert={setShowAlert} />}
171
286
  <OpenAllInvocationsModal
172
287
  isOpen={isModalOpen}
@@ -182,6 +297,22 @@ export const CheckboxesActions = ({
182
297
  );
183
298
  };
184
299
 
300
+ ActionsKebab.propTypes = {
301
+ selectedIds: PropTypes.array.isRequired,
302
+ failedCount: PropTypes.number.isRequired,
303
+ isTaskCancelable: PropTypes.bool,
304
+ hasCancelPermission: PropTypes.bool,
305
+ handleTaskAction: PropTypes.func.isRequired,
306
+ handleOpenHosts: PropTypes.func.isRequired,
307
+ isDropdownOpen: PropTypes.bool.isRequired,
308
+ setIsDropdownOpen: PropTypes.func.isRequired,
309
+ };
310
+
311
+ ActionsKebab.defaultProps = {
312
+ isTaskCancelable: false,
313
+ hasCancelPermission: false,
314
+ };
315
+
185
316
  CheckboxesActions.propTypes = {
186
317
  selectedIds: PropTypes.array.isRequired,
187
318
  failedCount: PropTypes.number.isRequired,
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable camelcase */
2
2
  import React from 'react';
3
- import { foremanUrl } from 'foremanReact/common/helpers';
4
3
  import { translate as __ } from 'foremanReact/common/I18n';
4
+ import { foremanUrl } from 'foremanReact/common/helpers';
5
5
  import { useForemanHostDetailsPageUrl } from 'foremanReact/Root/Context/ForemanContext';
6
6
  import JobStatusIcon from '../react_app/components/RecentJobsCard/JobStatusIcon';
7
7
 
@@ -31,8 +31,7 @@ export const LIST_TEMPLATE_INVOCATIONS = 'LIST_TEMPLATE_INVOCATIONS';
31
31
  export const templateInvocationPageUrl = (hostID, jobID) =>
32
32
  `/job_invocations_detail/${jobID}/host_invocation/${hostID}`;
33
33
 
34
- export const jobInvocationDetailsUrl = id =>
35
- `/experimental/job_invocations_detail/${id}`;
34
+ export const jobInvocationDetailsUrl = id => `/job_invocations/${id}`;
36
35
 
37
36
  export const STATUS = {
38
37
  PENDING: 'pending',
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
2
2
  import React, { useEffect, useState } from 'react';
3
3
  import { useDispatch, useSelector } from 'react-redux';
4
4
  import { Button, Split, SplitItem } from '@patternfly/react-core';
5
+ import { UndoIcon } from '@patternfly/react-icons';
5
6
  import {
6
7
  Dropdown,
7
8
  DropdownItem,
@@ -189,7 +190,8 @@ const JobInvocationToolbarButtons = ({ jobId, data }) => {
189
190
  <DropdownSeparator ouiaId="dropdown-separator-2" key="separator-2" />,
190
191
  <DropdownItem
191
192
  ouiaId="legacy-ui-dropdown-item"
192
- href={`/job_invocations/${jobId}`}
193
+ icon={<UndoIcon />}
194
+ href={`/legacy/job_invocations/${jobId}`}
193
195
  key="legacy-ui"
194
196
  >
195
197
  {__('Legacy UI')}
@@ -96,9 +96,7 @@ describe('JobInvocationDetailPage', () => {
96
96
  );
97
97
 
98
98
  expect(screen.getByText('Description')).toBeInTheDocument();
99
- expect(
100
- container.querySelector('.chart-donut .pf-v5-c-chart')
101
- ).toBeInTheDocument();
99
+ expect(container.querySelector('.chart-donut')).toBeInTheDocument();
102
100
  expect(screen.getByText('2/6')).toBeInTheDocument();
103
101
  expect(screen.getByText('Systems')).toBeInTheDocument();
104
102
  expect(screen.getByText('System status')).toBeInTheDocument();
@@ -178,7 +176,7 @@ describe('JobInvocationDetailPage', () => {
178
176
  .getByText('Legacy UI')
179
177
  .closest('a')
180
178
  .getAttribute('href')
181
- ).toEqual(`/job_invocations/${jobId}`);
179
+ ).toEqual(`/legacy/job_invocations/${jobId}`);
182
180
  });
183
181
 
184
182
  it('shows scheduled date', async () => {
@@ -1,16 +1,20 @@
1
+ import '@testing-library/jest-dom/extend-expect';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import axios from 'axios';
4
+ import { foremanUrl } from 'foremanReact/common/helpers';
5
+ import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
1
6
  import React from 'react';
2
7
  import { Provider } from 'react-redux';
3
8
  import configureStore from 'redux-mock-store';
4
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
5
- import '@testing-library/jest-dom/extend-expect';
6
- import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
9
+
7
10
  import { CheckboxesActions } from '../CheckboxesActions';
8
11
  import * as selectors from '../JobInvocationSelectors';
9
12
  import { PopupAlert } from '../OpenAllInvocationsModal';
10
13
 
14
+ jest.mock('axios');
11
15
  jest.mock('foremanReact/common/hooks/API/APIHooks');
12
-
13
16
  jest.mock('../JobInvocationSelectors');
17
+
14
18
  jest.mock('../JobInvocationConstants', () => ({
15
19
  ...jest.requireActual('../JobInvocationConstants'),
16
20
  templateInvocationPageUrl: jest.fn(
@@ -18,18 +22,15 @@ jest.mock('../JobInvocationConstants', () => ({
18
22
  ),
19
23
  DIRECT_OPEN_HOST_LIMIT: 3,
20
24
  }));
25
+
21
26
  selectors.selectItems.mockImplementation(() => ({
22
27
  targeting: { search_query: 'name~*' },
23
28
  }));
24
- selectors.selectHasPermission.mockImplementation(() => () => () => true);
29
+ selectors.selectHasPermission.mockImplementation(() => () => true);
30
+ selectors.selectTaskCancelable.mockImplementation(() => true);
31
+
25
32
  const mockStore = configureStore([]);
26
- const store = mockStore({
27
- templateInvocation: {
28
- permissions: {
29
- execute_jobs: true,
30
- },
31
- },
32
- });
33
+ const store = mockStore({});
33
34
 
34
35
  describe('TableToolbarActions', () => {
35
36
  const jobID = '42';
@@ -38,6 +39,7 @@ describe('TableToolbarActions', () => {
38
39
  beforeEach(() => {
39
40
  openSpy = jest.spyOn(window, 'open').mockImplementation(jest.fn());
40
41
  useAPI.mockClear();
42
+ axios.post.mockResolvedValue({ data: {} });
41
43
  useAPI.mockReturnValue({
42
44
  response: null,
43
45
  status: 'initial',
@@ -68,7 +70,7 @@ describe('TableToolbarActions', () => {
68
70
  expect(openSpy).toHaveBeenCalledTimes(selectedIds.length);
69
71
  });
70
72
 
71
- test('shows modal when results length is greater than 3', () => {
73
+ test('shows modal when results length is greater than 3', async () => {
72
74
  const selectedIds = [1, 2, 3, 4];
73
75
  render(
74
76
  <Provider store={store}>
@@ -83,13 +85,13 @@ describe('TableToolbarActions', () => {
83
85
  screen.getByLabelText(/open all template invocations in new tab/i)
84
86
  );
85
87
  expect(
86
- screen.getByRole('heading', {
87
- name: /open all %s invocations in new tabs \+ selected/i,
88
+ await screen.findByRole('heading', {
89
+ name: /open all.*invocations in new tabs \+ selected/i,
88
90
  })
89
91
  ).toBeInTheDocument();
90
92
  });
91
93
 
92
- test('shows alert when popups are blocked', () => {
94
+ test('shows alert when popups are blocked', async () => {
93
95
  openSpy.mockReturnValue(null);
94
96
  const selectedIds = [1, 2];
95
97
  render(
@@ -105,7 +107,7 @@ describe('TableToolbarActions', () => {
105
107
  screen.getByLabelText(/open all template invocations in new tab/i)
106
108
  );
107
109
  expect(
108
- screen.getByText(/Popups are blocked by your browser/)
110
+ await screen.findByText(/Popups are blocked by your browser/)
109
111
  ).toBeInTheDocument();
110
112
  });
111
113
  });
@@ -123,23 +125,23 @@ describe('TableToolbarActions', () => {
123
125
  </Provider>
124
126
  );
125
127
  fireEvent.click(screen.getByLabelText(/actions dropdown toggle/i));
126
- fireEvent.click(screen.getByText(/open all failed runs/i));
128
+ fireEvent.click(await screen.findByText(/open all failed runs/i));
127
129
  await waitFor(() => {
128
130
  expect(openSpy).toHaveBeenCalledTimes(failedHosts.length);
129
131
  });
130
132
  });
131
133
 
132
- test('shows modal when results length is greater than 3', () => {
134
+ test('shows modal when results length is greater than 3', async () => {
133
135
  render(
134
136
  <Provider store={store}>
135
137
  <CheckboxesActions selectedIds={[]} failedCount={4} jobID={jobID} />
136
138
  </Provider>
137
139
  );
138
140
  fireEvent.click(screen.getByLabelText(/actions dropdown toggle/i));
139
- fireEvent.click(screen.getByText(/open all failed runs/i));
141
+ fireEvent.click(await screen.findByText(/open all failed runs/i));
140
142
  expect(
141
- screen.getByRole('heading', {
142
- name: /open all %s invocations in new tabs \+ failed/i,
143
+ await screen.findByRole('heading', {
144
+ name: /open all.*invocations in new tabs \+ failed/i,
143
145
  })
144
146
  ).toBeInTheDocument();
145
147
  });
@@ -152,7 +154,7 @@ describe('TableToolbarActions', () => {
152
154
  );
153
155
  expect(useAPI).toHaveBeenCalledWith(
154
156
  'get',
155
- `foreman/api/job_invocations/${jobID}/hosts`,
157
+ foremanUrl(`/api/job_invocations/${jobID}/hosts`),
156
158
  expect.objectContaining({
157
159
  skip: true,
158
160
  })
@@ -193,9 +195,9 @@ describe('TableToolbarActions', () => {
193
195
  );
194
196
  const rerunLink = screen.getByRole('link', { name: /rerun/i });
195
197
  expect(rerunLink).toBeEnabled();
196
- const expectedSearchParams = new URLSearchParams();
197
- selectedIds.forEach(id => expectedSearchParams.append('host_ids[]', id));
198
- const expectedHref = `foreman/job_invocations/42/rerun?search=(name~*) AND ((id ^ (101, 102, 103)))`;
198
+ const expectedHref = foremanUrl(
199
+ `/job_invocations/42/rerun?search=(name~*) AND ((id ^ (101, 102, 103)))`
200
+ );
199
201
  expect(rerunLink).toHaveAttribute('href', expectedHref);
200
202
  });
201
203
  });
@@ -5,32 +5,31 @@ import {
5
5
  PageSectionVariants,
6
6
  Skeleton,
7
7
  } from '@patternfly/react-core';
8
- import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
8
+ import React, { useEffect, useState } from 'react';
9
9
  import { translate as __, documentLocale } from 'foremanReact/common/I18n';
10
- import { stopInterval } from 'foremanReact/redux/middlewares/IntervalMiddleware';
10
+ import { useDispatch, useSelector } from 'react-redux';
11
11
  import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
12
- import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
13
12
  import PropTypes from 'prop-types';
14
- import React, { useEffect, useState } from 'react';
15
- import { useDispatch, useSelector } from 'react-redux';
13
+ import SkeletonLoader from 'foremanReact/components/common/SkeletonLoader';
14
+ import { stopInterval } from 'foremanReact/redux/middlewares/IntervalMiddleware';
15
+ import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
16
16
 
17
17
  import { JobAdditionInfo } from './JobAdditionInfo';
18
+ import JobInvocationHostTable from './JobInvocationHostTable';
19
+ import JobInvocationOverview from './JobInvocationOverview';
20
+ import JobInvocationSystemStatusChart from './JobInvocationSystemStatusChart';
21
+ import JobInvocationToolbarButtons from './JobInvocationToolbarButtons';
18
22
  import { getJobInvocation, getTask } from './JobInvocationActions';
23
+ import './JobInvocationDetail.scss';
19
24
  import {
20
25
  CURRENT_PERMISSIONS,
21
- currentPermissionsUrl,
22
26
  DATE_OPTIONS,
23
27
  JOB_INVOCATION_KEY,
24
28
  STATUS,
25
29
  STATUS_UPPERCASE,
30
+ currentPermissionsUrl,
26
31
  } from './JobInvocationConstants';
27
- import JobInvocationHostTable from './JobInvocationHostTable';
28
- import JobInvocationOverview from './JobInvocationOverview';
29
32
  import { selectItems } from './JobInvocationSelectors';
30
- import JobInvocationSystemStatusChart from './JobInvocationSystemStatusChart';
31
- import JobInvocationToolbarButtons from './JobInvocationToolbarButtons';
32
-
33
- import './JobInvocationDetail.scss';
34
33
 
35
34
  const JobInvocationDetailPage = ({
36
35
  match: {
@@ -16,7 +16,7 @@ const ForemanREXRoutes = [
16
16
  render: props => <JobWizardPageRerun {...props} />,
17
17
  },
18
18
  {
19
- path: '/experimental/job_invocations_detail/:id',
19
+ path: '/job_invocations/:id',
20
20
  exact: true,
21
21
  render: props => <JobInvocationDetailPage {...props} />,
22
22
  },
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_remote_execution
3
3
  version: !ruby/object:Gem::Version
4
- version: 16.0.5
4
+ version: 16.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Remote Execution team
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-07-28 00:00:00.000000000 Z
10
+ date: 2025-08-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: deface