foreman_remote_execution 16.2.1 → 16.2.2

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: 9448cb2747c87518b30f5f411744846a45613ab7cffb13dbe964a04426ead5ab
4
- data.tar.gz: 027d7b4e71254b490293085a86356bdf128c82bb542a4bfdd3799330b59fc89a
3
+ metadata.gz: 3241ac38107bfc2d42a461621df7f94bcd05220e7443572cc9b16c6c50986316
4
+ data.tar.gz: 27e015c4f820e21d746f703f1619c40ec73ca88b089846232e2b256841a9c24e
5
5
  SHA512:
6
- metadata.gz: f08de421e1092fff0a4fd10b9bf6651c6ed367a2b912ea8c0f47f8d3f8008db648235970b06b81d4396c0d8e753584ad47171df7b4ae83b7b038a5bcafee176b
7
- data.tar.gz: 99ed31aede7f76fcc53b81abb171399cc544e03520c3a376c90e10c727f1f81ff38c1e93cd2a7c0e23992c991822d750bc630c18d7eb0228d2404bc81e4b8922
6
+ metadata.gz: 2b92135ebe56b899799ffaf114de5023a4eda5b16b282bf6149387be0caa603968c8639d4a88490f57717f186ed6e4297bbcb04fa93b6a9072abb5320962a853
7
+ data.tar.gz: 336bf208eb1afbcfe7858b51e703e9e1d5df0f536fcec9eb4d619ede3822c015f4f41fbccc0284875cc94733f23209e5b3699c9331aa87b522b3d9dd02a368d0
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '16.2.1'.freeze
2
+ VERSION = '16.2.2'.freeze
3
3
  end
@@ -14,23 +14,21 @@ import {
14
14
  } from '@patternfly/react-icons';
15
15
  import axios from 'axios';
16
16
  import { foremanUrl } from 'foremanReact/common/helpers';
17
- import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
18
17
  import { translate as __, sprintf } from 'foremanReact/common/I18n';
19
18
  import { addToast } from 'foremanReact/components/ToastsList';
20
19
  import PropTypes from 'prop-types';
21
- import React, { useState } from 'react';
20
+ import React, { useEffect, useState } from 'react';
22
21
  import { useDispatch, useSelector } from 'react-redux';
23
22
 
24
23
  import {
25
24
  DIRECT_OPEN_HOST_LIMIT,
26
- MAX_HOSTS_API_SIZE,
27
25
  templateInvocationPageUrl,
28
26
  } from './JobInvocationConstants';
29
27
  import {
30
28
  selectHasPermission,
31
29
  selectTaskCancelable,
32
30
  } from './JobInvocationSelectors';
33
- import OpenAllInvocationsModal, { PopupAlert } from './OpenAllInvocationsModal';
31
+ import OpenAllInvocationsModal from './OpenAllInvocationsModal';
34
32
 
35
33
  /* eslint-disable camelcase */
36
34
  const ActionsKebab = ({
@@ -73,7 +71,7 @@ const ActionsKebab = ({
73
71
  onClick={() => handleOpenHosts('failed')}
74
72
  isDisabled={failedCount === 0}
75
73
  >
76
- {sprintf(__('Open all failed runs (%s)'), failedCount)}
74
+ {sprintf(__('Open all failed runs on this page (%s)'), failedCount)}
77
75
  </DropdownItem>,
78
76
  ];
79
77
 
@@ -104,17 +102,18 @@ const ActionsKebab = ({
104
102
 
105
103
  export const CheckboxesActions = ({
106
104
  selectedIds,
107
- failedCount,
105
+ allJobs,
108
106
  jobID,
109
107
  filter,
110
108
  bulkParams,
109
+ setShowAlert,
111
110
  }) => {
112
111
  const [isModalOpen, setIsModalOpen] = useState(false);
113
- const [showAlert, setShowAlert] = useState(false);
114
112
  const [isOpenFailed, setIsOpenFailed] = useState(false);
115
113
  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
116
114
  const isTaskCancelable = useSelector(selectTaskCancelable);
117
115
  const dispatch = useDispatch();
116
+ const [toBeOpened, setToBeOpened] = useState([]);
118
117
 
119
118
  const hasCreatePermission = useSelector(
120
119
  selectHasPermission('create_job_invocations')
@@ -129,19 +128,14 @@ export const CheckboxesActions = ({
129
128
  : '';
130
129
  const combinedQuery = `${bulkParams}${filterQuery}`;
131
130
 
132
- const { response: failedHostsData } = useAPI(
133
- 'get',
134
- foremanUrl(`/api/job_invocations/${jobID}/hosts`),
135
- {
136
- params: {
137
- per_page: MAX_HOSTS_API_SIZE,
138
- search: `job_invocation.result = failed`,
139
- },
140
- skip: failedCount === 0,
141
- }
142
- );
131
+ const [failedHosts, setFailedHosts] = useState([]);
143
132
 
144
- const failedHosts = failedHostsData?.results || [];
133
+ useEffect(() => {
134
+ const failed = allJobs.filter(i => i.job_status === 'error');
135
+ setFailedHosts(failed);
136
+ }, [allJobs]);
137
+
138
+ const failedCount = failedHosts.length;
145
139
 
146
140
  const openLink = url => {
147
141
  const newWin = window.open(url);
@@ -151,24 +145,35 @@ export const CheckboxesActions = ({
151
145
  }
152
146
  };
153
147
 
148
+ const openTabs = tabs => {
149
+ tabs.forEach(open => {
150
+ const openId = open.id ?? open;
151
+ openLink(templateInvocationPageUrl(openId, jobID));
152
+ });
153
+ };
154
+
154
155
  const handleOpenHosts = async (type = 'all') => {
155
156
  if (type === 'failed') {
156
157
  if (failedCount <= DIRECT_OPEN_HOST_LIMIT) {
157
- failedHosts.forEach(host =>
158
- openLink(templateInvocationPageUrl(host.id, jobID))
159
- );
158
+ openTabs(failedHosts);
160
159
  return;
161
160
  }
161
+ setToBeOpened(failedHosts);
162
162
  setIsOpenFailed(true);
163
163
  setIsModalOpen(true);
164
164
  return;
165
165
  }
166
166
 
167
+ if (selectedIds.length === 0) {
168
+ selectedIds = allJobs;
169
+ }
170
+
167
171
  if (selectedIds.length <= DIRECT_OPEN_HOST_LIMIT) {
168
- selectedIds.forEach(id => openLink(templateInvocationPageUrl(id, jobID)));
172
+ openTabs(selectedIds);
169
173
  return;
170
174
  }
171
175
 
176
+ setToBeOpened(selectedIds);
172
177
  setIsOpenFailed(false);
173
178
  setIsModalOpen(true);
174
179
  };
@@ -237,13 +242,19 @@ export const CheckboxesActions = ({
237
242
  <Button
238
243
  aria-label="open all template invocations in new tab"
239
244
  className="open-all-button"
240
- isDisabled={selectedIds.length === 0}
245
+ isDisabled={allJobs.length === 0}
241
246
  isInline
242
247
  onClick={() => handleOpenHosts('all')}
243
248
  ouiaId="template-invocation-new-tab-button"
244
249
  variant="link"
245
250
  >
246
- <Tooltip content={__('Open selected in new tab')}>
251
+ <Tooltip
252
+ content={
253
+ selectedIds.length === 0
254
+ ? __('Open all rows of the table in new tabs')
255
+ : __('Open selected in new tab')
256
+ }
257
+ >
247
258
  <OutlinedWindowRestoreIcon />
248
259
  </Tooltip>
249
260
  </Button>
@@ -281,16 +292,13 @@ export const CheckboxesActions = ({
281
292
  isDropdownOpen={isDropdownOpen}
282
293
  setIsDropdownOpen={setIsDropdownOpen}
283
294
  />
284
- {showAlert && <PopupAlert setShowAlert={setShowAlert} />}
285
295
  <OpenAllInvocationsModal
286
296
  isOpen={isModalOpen}
287
297
  onClose={() => setIsModalOpen(false)}
288
298
  failedCount={failedCount}
289
- failedHosts={failedHosts}
290
- jobID={jobID}
291
299
  isOpenFailed={isOpenFailed}
292
- setShowAlert={setShowAlert}
293
300
  selectedIds={selectedIds}
301
+ confirmCallback={() => openTabs(toBeOpened)}
294
302
  />
295
303
  </>
296
304
  );
@@ -314,10 +322,11 @@ ActionsKebab.defaultProps = {
314
322
 
315
323
  CheckboxesActions.propTypes = {
316
324
  selectedIds: PropTypes.array.isRequired,
317
- failedCount: PropTypes.number.isRequired,
325
+ allJobs: PropTypes.array.isRequired,
318
326
  jobID: PropTypes.string.isRequired,
319
327
  bulkParams: PropTypes.string,
320
328
  filter: PropTypes.string,
329
+ setShowAlert: PropTypes.func.isRequired,
321
330
  };
322
331
 
323
332
  CheckboxesActions.defaultProps = {
@@ -17,7 +17,6 @@ export const GET_REPORT_TEMPLATES = 'GET_REPORT_TEMPLATES';
17
17
  export const GET_REPORT_TEMPLATE_INPUTS = 'GET_REPORT_TEMPLATE_INPUTS';
18
18
  export const JOB_INVOCATION_HOSTS = 'JOB_INVOCATION_HOSTS';
19
19
  export const GET_TEMPLATE_INVOCATION = 'GET_TEMPLATE_INVOCATION';
20
- export const MAX_HOSTS_API_SIZE = 100;
21
20
  export const DIRECT_OPEN_HOST_LIMIT = 3;
22
21
  export const ALL_JOB_HOSTS = 'ALL_JOB_HOSTS';
23
22
  export const currentPermissionsUrl = foremanUrl(
@@ -8,9 +8,10 @@ import {
8
8
  ToolbarItem,
9
9
  } from '@patternfly/react-core';
10
10
  import { ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table';
11
+ import { useDispatch } from 'react-redux';
12
+ import { APIActions } from 'foremanReact/redux/API';
11
13
  import { translate as __ } from 'foremanReact/common/I18n';
12
14
  import { foremanUrl } from 'foremanReact/common/helpers';
13
- import { useAPI } from 'foremanReact/common/hooks/API/APIHooks';
14
15
  import { RowSelectTd } from 'foremanReact/components/HostsIndex/RowSelectTd';
15
16
  import SelectAllCheckbox from 'foremanReact/components/PF4/TableIndexPage/Table/SelectAllCheckbox';
16
17
  import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table';
@@ -26,22 +27,20 @@ import PropTypes from 'prop-types';
26
27
  import React, { useEffect, useMemo, useState, useRef } from 'react';
27
28
  import { FormattedMessage } from 'react-intl';
28
29
  import { useHistory } from 'react-router-dom';
29
- import URI from 'urijs';
30
+ import { useForemanSettings } from 'foremanReact/Root/Context/ForemanContext';
30
31
  import { CheckboxesActions } from './CheckboxesActions';
31
32
  import DropdownFilter from './DropdownFilter';
32
33
  import Columns, {
33
34
  JOB_INVOCATION_HOSTS,
34
- MAX_HOSTS_API_SIZE,
35
- STATUS_UPPERCASE,
36
35
  LIST_TEMPLATE_INVOCATIONS,
36
+ STATUS_UPPERCASE,
37
37
  ALL_JOB_HOSTS,
38
38
  } from './JobInvocationConstants';
39
- import { PopupAlert } from './OpenAllInvocationsModal';
40
39
  import { TemplateInvocation } from './TemplateInvocation';
41
40
  import { RowActions } from './TemplateInvocationComponents/TemplateActionButtons';
41
+ import { PopupAlert } from './OpenAllInvocationsModal';
42
42
 
43
43
  const JobInvocationHostTable = ({
44
- failedCount,
45
44
  id,
46
45
  initialFilter,
47
46
  onFilterUpdate,
@@ -50,19 +49,29 @@ const JobInvocationHostTable = ({
50
49
  }) => {
51
50
  const columns = Columns();
52
51
  const columnNamesKeys = Object.keys(columns);
53
- const apiOptions = { key: JOB_INVOCATION_HOSTS };
52
+
54
53
  const history = useHistory();
55
- const [selectedFilter, setSelectedFilter] = useState(initialFilter);
54
+ const dispatch = useDispatch();
55
+
56
+ const [showAlert, setShowAlert] = useState(false);
57
+
58
+ const [apiResponse, setApiResponse] = useState([]);
59
+ const [status, setStatus] = useState(STATUS_UPPERCASE.PENDING);
60
+ const [allHostsIds, setAllHostsIds] = useState([]);
61
+
62
+ // Expansive items
56
63
  const [expandedHost, setExpandedHost] = useState([]);
57
64
  const prevStatusLabel = useRef(statusLabel);
58
65
 
59
- useEffect(() => {
60
- if (initialFilter !== selectedFilter) {
61
- wrapSetSelectedFilter(initialFilter);
62
- }
63
- // eslint-disable-next-line react-hooks/exhaustive-deps
64
- }, [initialFilter]);
66
+ const isHostExpanded = host => expandedHost.includes(host);
67
+ const setHostExpanded = (host, isExpanding = true) =>
68
+ setExpandedHost(prevExpanded => {
69
+ const otherExpandedHosts = prevExpanded.filter(h => h !== host);
70
+ return isExpanding ? [...otherExpandedHosts, host] : otherExpandedHosts;
71
+ });
65
72
 
73
+ // Page table params
74
+ // Parse URL
66
75
  const {
67
76
  searchParam: urlSearchQuery = '',
68
77
  page: urlPage,
@@ -70,10 +79,27 @@ const JobInvocationHostTable = ({
70
79
  order: urlOrder,
71
80
  } = useUrlParams();
72
81
 
73
- const constructFilter = (
74
- filter = selectedFilter,
75
- search = urlSearchQuery
76
- ) => {
82
+ const { perPage: foremanPerPage } = useForemanSettings();
83
+
84
+ // default
85
+ const defaultParams = useMemo(
86
+ () => ({
87
+ page: urlPage ? Number(urlPage) : 1,
88
+ per_page: urlPerPage || Number(urlPerPage) || foremanPerPage,
89
+ order: urlOrder || '',
90
+ }),
91
+ [urlPage, urlPerPage, foremanPerPage, urlOrder]
92
+ );
93
+
94
+ // Page row for table
95
+ const { pageRowCount } = getPageStats({
96
+ total: apiResponse?.total || 0,
97
+ page: apiResponse?.page || urlPage || 1,
98
+ perPage: apiResponse?.per_page || urlPerPage || 0,
99
+ });
100
+
101
+ // Search filter
102
+ const constructFilter = (filter = initialFilter, search = urlSearchQuery) => {
77
103
  const dropdownFilterClause =
78
104
  filter && filter !== 'all_statuses'
79
105
  ? `job_invocation.result = ${filter}`
@@ -85,68 +111,93 @@ const JobInvocationHostTable = ({
85
111
  .join(' AND ');
86
112
  };
87
113
 
88
- const defaultParams = useMemo(
89
- () => ({
90
- ...(urlPage ? { page: Number(urlPage) } : {}),
91
- ...(urlPerPage ? { per_page: Number(urlPerPage) } : {}),
92
- ...(urlOrder ? { order: urlOrder } : {}),
93
- }),
94
- [urlPage, urlPerPage, urlOrder]
95
- );
114
+ const handleResponse = (data, key) => {
115
+ if (key === JOB_INVOCATION_HOSTS) {
116
+ const ids = data.data.results.map(i => i.id);
96
117
 
97
- useAPI('get', `/job_invocations/${id}/hosts`, {
98
- params: {
99
- search: defaultParams.search,
100
- },
101
- key: LIST_TEMPLATE_INVOCATIONS,
102
- });
103
- const { response, status, setAPIOptions } = useAPI(
104
- 'get',
105
- `/api/job_invocations/${id}/hosts`,
106
- {
107
- params: defaultParams,
118
+ setApiResponse(data.data);
119
+ setAllHostsIds(ids);
108
120
  }
109
- );
110
121
 
111
- const [allPagesResponse, setAllPagesResponse] = useState([]);
112
- const apiAllParams = {
113
- page: 1,
114
- per_page: Math.min(response?.subtotal || 1, MAX_HOSTS_API_SIZE),
115
- search: constructFilter(selectedFilter, urlSearchQuery),
122
+ setStatus(STATUS_UPPERCASE.RESOLVED);
123
+ };
124
+
125
+ // Call hosts data with params
126
+ const makeApiCall = (requestParams, callParams = {}) => {
127
+ dispatch(
128
+ APIActions.get({
129
+ key: callParams.key ?? ALL_JOB_HOSTS,
130
+ url: callParams.url ?? `/api/job_invocations/${id}/hosts`,
131
+ params: requestParams,
132
+ handleSuccess: data => handleResponse(data, callParams.key),
133
+ handleError: () => setStatus(STATUS_UPPERCASE.ERROR),
134
+ errorToast: ({ response }) =>
135
+ response?.data?.error?.full_messages?.[0] || response,
136
+ })
137
+ );
116
138
  };
117
139
 
118
- const { response: allResponse, setAPIOptions: setAllAPIOptions } = useAPI(
119
- 'get',
120
- `/api/job_invocations/${id}/hosts`,
121
- {
122
- params: apiAllParams,
123
- key: ALL_JOB_HOSTS,
140
+ const filterApiCall = newAPIOptions => {
141
+ const newParams = newAPIOptions?.params ?? newAPIOptions ?? {};
142
+
143
+ const filterSearch = constructFilter(
144
+ initialFilter,
145
+ newParams.search ?? urlSearchQuery
146
+ );
147
+
148
+ const finalParams = {
149
+ ...defaultParams,
150
+ ...newParams,
151
+ };
152
+
153
+ if (filterSearch !== '') {
154
+ finalParams.search = filterSearch;
124
155
  }
125
- );
126
156
 
157
+ makeApiCall(finalParams, { key: JOB_INVOCATION_HOSTS });
158
+
159
+ const urlSearchParams = new URLSearchParams(window.location.search);
160
+
161
+ ['page', 'per_page', 'order'].forEach(key => {
162
+ if (finalParams[key]) urlSearchParams.set(key, finalParams[key]);
163
+ });
164
+
165
+ history.push({ search: urlSearchParams.toString() });
166
+ };
167
+
168
+ // Filter change
169
+ const handleFilterChange = newFilter => {
170
+ onFilterUpdate(newFilter);
171
+ };
172
+
173
+ // Effects
174
+ // run after mount
127
175
  useEffect(() => {
128
- if (response?.subtotal) {
129
- setAllAPIOptions(prevOptions => ({
130
- ...prevOptions,
131
- params: apiAllParams,
132
- }));
176
+ // Job Invo template load
177
+ makeApiCall(
178
+ {},
179
+ {
180
+ url: `/job_invocations/${id}/hosts`,
181
+ key: LIST_TEMPLATE_INVOCATIONS,
182
+ }
183
+ );
184
+
185
+ if (initialFilter === '') {
186
+ onFilterUpdate('all_statuses');
133
187
  }
188
+
134
189
  // eslint-disable-next-line react-hooks/exhaustive-deps
135
- }, [response?.subtotal, selectedFilter, urlSearchQuery]);
190
+ }, []);
136
191
 
137
192
  useEffect(() => {
138
- if (allResponse?.results) {
139
- setAllPagesResponse(allResponse.results);
140
- }
141
- }, [allResponse]);
193
+ if (initialFilter !== '') filterApiCall();
142
194
 
143
- useEffect(() => {
144
195
  if (statusLabel !== prevStatusLabel.current) {
145
- setAPIOptions(prevOptions => ({ ...prevOptions }));
146
196
  prevStatusLabel.current = statusLabel;
197
+ filterApiCall();
147
198
  }
148
199
  // eslint-disable-next-line react-hooks/exhaustive-deps
149
- }, [statusLabel]);
200
+ }, [initialFilter, statusLabel, id]);
150
201
 
151
202
  const {
152
203
  updateSearchQuery: updateSearchQueryBulk,
@@ -155,11 +206,11 @@ const JobInvocationHostTable = ({
155
206
  exclusionSet,
156
207
  ...selectAllOptions
157
208
  } = useBulkSelect({
158
- results: response?.results,
209
+ results: apiResponse?.results,
159
210
  metadata: {
160
- total: response?.total,
161
- page: response?.page,
162
- selectable: response?.subtotal,
211
+ total: apiResponse?.total,
212
+ page: apiResponse?.page,
213
+ selectable: apiResponse?.subtotal,
163
214
  },
164
215
  initialSearchQuery: urlSearchQuery,
165
216
  });
@@ -175,35 +226,11 @@ const JobInvocationHostTable = ({
175
226
  isSelected,
176
227
  } = selectAllOptions;
177
228
 
178
- const allHostIds = allPagesResponse?.map(item => item.id) || [];
179
229
  const selectedIds =
180
230
  areAllRowsSelected() || exclusionSet.size > 0
181
- ? allHostIds.filter(hostId => !exclusionSet.has(hostId))
231
+ ? allHostsIds.filter(hostId => !exclusionSet.has(hostId))
182
232
  : Array.from(inclusionSet);
183
233
 
184
- const { pageRowCount } = getPageStats({
185
- total: response?.total || 0,
186
- page: response?.page || urlPage || 1,
187
- perPage: response?.per_page || urlPerPage || 0,
188
- });
189
-
190
- const selectionToolbar = (
191
- <ToolbarItem key="selectAll">
192
- <SelectAllCheckbox
193
- {...{
194
- selectAll,
195
- selectPage,
196
- selectNone,
197
- selectedCount,
198
- pageRowCount,
199
- }}
200
- totalCount={response?.total}
201
- areAllRowsOnPageSelected={areAllRowsOnPageSelected()}
202
- areAllRowsSelected={areAllRowsSelected()}
203
- />
204
- </ToolbarItem>
205
- );
206
-
207
234
  const controller = 'hosts';
208
235
  const memoDefaultSearchProps = useMemo(
209
236
  () => getControllerSearchProps(controller),
@@ -213,70 +240,40 @@ const JobInvocationHostTable = ({
213
240
  `/${controller}/auto_complete_search`
214
241
  );
215
242
 
216
- const wrapSetSelectedFilter = newFilter => {
217
- setSelectedFilter(newFilter);
218
- onFilterUpdate(newFilter);
219
-
220
- const filterSearch = constructFilter(newFilter, urlSearchQuery);
221
-
222
- const newParams = {
223
- ...defaultParams,
224
- page: 1,
225
- };
226
-
227
- if (filterSearch !== '') {
228
- newParams.search = filterSearch;
229
- }
230
-
231
- setAPIOptions(prev => ({ ...prev, params: newParams }));
232
-
233
- const urlSearchParams = new URLSearchParams(window.location.search);
234
- urlSearchParams.set('page', '1');
235
- history.push({ search: urlSearchParams.toString() });
236
- };
237
-
238
- const wrapSetAPIOptions = newAPIOptions => {
239
- const newParams = newAPIOptions?.params ?? newAPIOptions ?? {};
240
-
241
- const filterSearch = constructFilter(
242
- selectedFilter,
243
- newParams.search ?? urlSearchQuery
244
- );
245
-
246
- const mergedParams = {
247
- ...defaultParams,
248
- ...newParams,
249
- };
250
-
251
- if (filterSearch !== '') {
252
- mergedParams.search = filterSearch;
253
- } else if ('search' in mergedParams) {
254
- delete mergedParams.search;
255
- }
256
-
257
- setAPIOptions(prev => ({ ...prev, params: mergedParams }));
258
-
259
- const { search: _search, ...paramsForUrl } = mergedParams;
260
- const uri = new URI();
261
- uri.setSearch(paramsForUrl);
262
- history.push({ search: uri.search() });
263
- };
264
-
265
243
  const combinedResponse = {
266
244
  response: {
267
245
  search: urlSearchQuery,
268
246
  can_create: false,
269
- results: response?.results || [],
270
- total: response?.total || 0,
247
+ results: apiResponse?.results || [],
248
+ total: apiResponse?.total || 0,
271
249
  per_page: defaultParams?.perPage,
272
250
  page: defaultParams?.page,
273
- subtotal: response?.subtotal || 0,
274
- message: response?.message || 'error',
251
+ subtotal: apiResponse?.subtotal || 0,
252
+ message: apiResponse?.message || 'error',
275
253
  },
276
254
  status,
277
- setAPIOptions: wrapSetAPIOptions,
255
+ setAPIOptions: filterApiCall,
278
256
  };
279
257
 
258
+ const results = apiResponse.results ?? [];
259
+
260
+ const selectionToolbar = (
261
+ <ToolbarItem key="selectAll">
262
+ <SelectAllCheckbox
263
+ {...{
264
+ selectAll,
265
+ selectPage,
266
+ selectNone,
267
+ selectedCount,
268
+ pageRowCount,
269
+ }}
270
+ totalCount={apiResponse?.total}
271
+ areAllRowsOnPageSelected={areAllRowsOnPageSelected()}
272
+ areAllRowsSelected={areAllRowsSelected()}
273
+ />
274
+ </ToolbarItem>
275
+ );
276
+
280
277
  const customEmptyState = (
281
278
  <Tr ouiaId="table-empty">
282
279
  <Td colSpan={100}>
@@ -314,22 +311,11 @@ const JobInvocationHostTable = ({
314
311
  </Tr>
315
312
  );
316
313
 
317
- const { results = [] } = response;
318
-
319
- const isHostExpanded = host => expandedHost.includes(host);
320
- const setHostExpanded = (host, isExpanding = true) =>
321
- setExpandedHost(prevExpanded => {
322
- const otherExpandedHosts = prevExpanded.filter(h => h !== host);
323
- return isExpanding ? [...otherExpandedHosts, host] : otherExpandedHosts;
324
- });
325
- const [showAlert, setShowAlert] = useState(false);
326
-
327
314
  return (
328
315
  <>
329
316
  {showAlert && <PopupAlert setShowAlert={setShowAlert} />}
330
317
  <TableIndexPage
331
318
  apiUrl=""
332
- apiOptions={apiOptions}
333
319
  customSearchProps={memoDefaultSearchProps}
334
320
  controller="hosts"
335
321
  creatable={false}
@@ -338,16 +324,17 @@ const JobInvocationHostTable = ({
338
324
  customToolbarItems={[
339
325
  <DropdownFilter
340
326
  key="dropdown-filter"
341
- dropdownFilter={selectedFilter}
342
- setDropdownFilter={wrapSetSelectedFilter}
327
+ dropdownFilter={initialFilter}
328
+ setDropdownFilter={handleFilterChange}
343
329
  />,
344
330
  <CheckboxesActions
345
331
  bulkParams={selectedCount > 0 ? fetchBulkParams() : null}
346
332
  selectedIds={selectedIds}
347
- failedCount={failedCount}
333
+ allJobs={results}
348
334
  jobID={id}
349
335
  key="checkboxes-actions"
350
- filter={selectedFilter}
336
+ filter={initialFilter}
337
+ setShowAlert={setShowAlert}
351
338
  />,
352
339
  ]}
353
340
  selectionToolbar={selectionToolbar}
@@ -361,21 +348,21 @@ const JobInvocationHostTable = ({
361
348
  : null
362
349
  }
363
350
  params={{
364
- page: response?.page || Number(urlPage),
365
- per_page: response?.per_page || Number(urlPerPage),
351
+ page: defaultParams.page || Number(urlPage),
352
+ per_page: defaultParams.per_page || Number(urlPerPage),
366
353
  order: urlOrder,
367
354
  }}
368
- page={response?.page || Number(urlPage)}
369
- perPage={response?.per_page || Number(urlPerPage)}
370
- setParams={wrapSetAPIOptions}
371
- itemCount={response?.subtotal}
355
+ page={defaultParams.page || Number(urlPage)}
356
+ perPage={defaultParams.per_page || Number(urlPerPage)}
357
+ setParams={filterApiCall}
358
+ itemCount={apiResponse?.subtotal}
372
359
  results={results}
373
360
  url=""
374
361
  showCheckboxes
375
362
  refreshData={() => {}}
376
363
  errorMessage={
377
- status === STATUS_UPPERCASE.ERROR && response?.message
378
- ? response.message
364
+ status === STATUS_UPPERCASE.ERROR && apiResponse?.message
365
+ ? apiResponse.message
379
366
  : null
380
367
  }
381
368
  isPending={status === STATUS_UPPERCASE.PENDING}
@@ -439,7 +426,6 @@ const JobInvocationHostTable = ({
439
426
  JobInvocationHostTable.propTypes = {
440
427
  id: PropTypes.string.isRequired,
441
428
  targeting: PropTypes.object.isRequired,
442
- failedCount: PropTypes.number.isRequired,
443
429
  initialFilter: PropTypes.string.isRequired,
444
430
  statusLabel: PropTypes.string,
445
431
  onFilterUpdate: PropTypes.func,
@@ -7,10 +7,6 @@ import {
7
7
  import { sprintf, translate as __ } from 'foremanReact/common/I18n';
8
8
  import PropTypes from 'prop-types';
9
9
  import React from 'react';
10
- import {
11
- templateInvocationPageUrl,
12
- MAX_HOSTS_API_SIZE,
13
- } from './JobInvocationConstants';
14
10
 
15
11
  export const PopupAlert = ({ setShowAlert }) => (
16
12
  <Alert
@@ -25,21 +21,35 @@ export const PopupAlert = ({ setShowAlert }) => (
25
21
 
26
22
  const OpenAllInvocationsModal = ({
27
23
  failedCount,
28
- failedHosts,
29
24
  isOpen,
30
25
  isOpenFailed,
31
- jobID,
32
26
  onClose,
33
- setShowAlert,
34
27
  selectedIds,
28
+ confirmCallback,
35
29
  }) => {
36
- const modalText = isOpenFailed ? 'failed' : 'selected';
30
+ const modalText = () => {
31
+ if (isOpenFailed) return 'failed';
32
+ if (selectedIds.length > 0) return 'selected';
33
+ return 'current page';
34
+ };
37
35
 
38
- const openLink = url => {
39
- const newWin = window.open(url);
40
- if (!newWin || newWin.closed || typeof newWin.closed === 'undefined') {
41
- setShowAlert(true);
36
+ const selectedText = () => {
37
+ if (isOpenFailed) {
38
+ return (
39
+ <>
40
+ {__('The number of failed invocations is:')} <b>{failedCount}</b>
41
+ </>
42
+ );
42
43
  }
44
+ if (selectedIds.length > 0) {
45
+ return (
46
+ <>
47
+ {__('The number of selected invocations is:')}{' '}
48
+ <b>{selectedIds.length}</b>
49
+ </>
50
+ );
51
+ }
52
+ return <></>;
43
53
  };
44
54
 
45
55
  return (
@@ -48,7 +58,7 @@ const OpenAllInvocationsModal = ({
48
58
  isOpen={isOpen}
49
59
  onClose={onClose}
50
60
  ouiaId="template-invocation-new-tab-modal"
51
- title={sprintf(__('Open all %s invocations in new tabs'), modalText)}
61
+ title={sprintf(__('Open all %s invocations in new tabs'), modalText())}
52
62
  titleIconVariant="warning"
53
63
  width={590}
54
64
  actions={[
@@ -57,16 +67,7 @@ const OpenAllInvocationsModal = ({
57
67
  key="confirm"
58
68
  variant="primary"
59
69
  onClick={() => {
60
- const hostsToOpen = isOpenFailed
61
- ? failedHosts
62
- : selectedIds.map(id => ({ id }));
63
-
64
- hostsToOpen
65
- .slice(0, MAX_HOSTS_API_SIZE)
66
- .forEach(({ id }) =>
67
- openLink(templateInvocationPageUrl(id, jobID), '_blank')
68
- );
69
-
70
+ confirmCallback();
70
71
  onClose();
71
72
  }}
72
73
  >
@@ -84,30 +85,24 @@ const OpenAllInvocationsModal = ({
84
85
  >
85
86
  {sprintf(
86
87
  __('Are you sure you want to open all %s invocations in new tabs?'),
87
- modalText
88
+ modalText()
88
89
  )}
89
90
  <br />
90
- {__('This will open a new tab for each invocation. The maximum is 100.')}
91
- <br />
92
- {sprintf(__('The number of %s invocations is:'), modalText)}{' '}
93
- <b>{isOpenFailed ? failedCount : selectedIds.length}</b>
91
+ {selectedText()}
94
92
  </Modal>
95
93
  );
96
94
  };
97
95
 
98
96
  OpenAllInvocationsModal.propTypes = {
99
97
  failedCount: PropTypes.number.isRequired,
100
- failedHosts: PropTypes.array,
101
98
  isOpen: PropTypes.bool.isRequired,
102
99
  isOpenFailed: PropTypes.bool,
103
- jobID: PropTypes.string.isRequired,
104
100
  onClose: PropTypes.func.isRequired,
105
- setShowAlert: PropTypes.func.isRequired,
106
101
  selectedIds: PropTypes.array.isRequired,
102
+ confirmCallback: PropTypes.func.isRequired,
107
103
  };
108
104
 
109
105
  OpenAllInvocationsModal.defaultProps = {
110
- failedHosts: [],
111
106
  isOpenFailed: false,
112
107
  };
113
108
 
@@ -83,6 +83,7 @@ jest.mock('../JobInvocationHostTable.js', () => () => (
83
83
  const reportTemplateJobId = mockReportTemplatesResponse.results[0].id;
84
84
 
85
85
  const mockStore = configureMockStore([thunk]);
86
+ const props = { history: { push: jest.fn() } };
86
87
 
87
88
  describe('JobInvocationDetailPage', () => {
88
89
  it('renders main information', async () => {
@@ -91,7 +92,10 @@ describe('JobInvocationDetailPage', () => {
91
92
 
92
93
  const { container } = render(
93
94
  <Provider store={store}>
94
- <JobInvocationDetailPage match={{ params: { id: `${jobId}` } }} />
95
+ <JobInvocationDetailPage
96
+ match={{ params: { id: `${jobId}` } }}
97
+ {...props}
98
+ />
95
99
  </Provider>
96
100
  );
97
101
 
@@ -185,6 +189,7 @@ describe('JobInvocationDetailPage', () => {
185
189
  <Provider store={store}>
186
190
  <JobInvocationDetailPage
187
191
  match={{ params: { id: `${jobInvocationDataScheduled.id}` } }}
192
+ {...props}
188
193
  />
189
194
  </Provider>
190
195
  );
@@ -201,7 +206,10 @@ describe('JobInvocationDetailPage', () => {
201
206
  const store = mockStore(jobInvocationDataRecurring);
202
207
  render(
203
208
  <Provider store={store}>
204
- <JobInvocationDetailPage match={{ params: { id: `${jobId}` } }} />
209
+ <JobInvocationDetailPage
210
+ match={{ params: { id: `${jobId}` } }}
211
+ {...props}
212
+ />
205
213
  </Provider>
206
214
  );
207
215
 
@@ -32,6 +32,29 @@ selectors.selectTaskCancelable.mockImplementation(() => true);
32
32
  const mockStore = configureStore([]);
33
33
  const store = mockStore({});
34
34
 
35
+ const allJobs = [
36
+ {
37
+ id: 1,
38
+ job_status: 'error',
39
+ },
40
+ {
41
+ id: 2,
42
+ job_status: 'error',
43
+ },
44
+ {
45
+ id: 3,
46
+ job_status: 'error',
47
+ },
48
+ {
49
+ id: 4,
50
+ job_status: 'error',
51
+ },
52
+ {
53
+ id: 5,
54
+ job_status: 'error',
55
+ },
56
+ ];
57
+
35
58
  describe('TableToolbarActions', () => {
36
59
  const jobID = '42';
37
60
  let openSpy;
@@ -58,8 +81,9 @@ describe('TableToolbarActions', () => {
58
81
  <Provider store={store}>
59
82
  <CheckboxesActions
60
83
  selectedIds={selectedIds}
61
- failedCount={0}
62
84
  jobID={jobID}
85
+ allJobs={allJobs}
86
+ setShowAlert={jest.fn()}
63
87
  />
64
88
  </Provider>
65
89
  );
@@ -76,8 +100,9 @@ describe('TableToolbarActions', () => {
76
100
  <Provider store={store}>
77
101
  <CheckboxesActions
78
102
  selectedIds={selectedIds}
79
- failedCount={0}
80
103
  jobID={jobID}
104
+ allJobs={allJobs}
105
+ setShowAlert={jest.fn()}
81
106
  />
82
107
  </Provider>
83
108
  );
@@ -94,47 +119,52 @@ describe('TableToolbarActions', () => {
94
119
  test('shows alert when popups are blocked', async () => {
95
120
  openSpy.mockReturnValue(null);
96
121
  const selectedIds = [1, 2];
122
+ const setShowMock = jest.fn();
97
123
  render(
98
124
  <Provider store={store}>
99
125
  <CheckboxesActions
100
126
  selectedIds={selectedIds}
101
- failedCount={0}
102
127
  jobID={jobID}
128
+ allJobs={allJobs}
129
+ setShowAlert={setShowMock}
103
130
  />
104
131
  </Provider>
105
132
  );
106
133
  fireEvent.click(
107
134
  screen.getByLabelText(/open all template invocations in new tab/i)
108
135
  );
109
- expect(
110
- await screen.findByText(/Popups are blocked by your browser/)
111
- ).toBeInTheDocument();
136
+ expect(setShowMock).toHaveBeenCalledWith(true);
112
137
  });
113
138
  });
114
139
 
115
140
  describe('Opening failed in new tabs', () => {
116
141
  test('opens links when results length is less than or equal to 3', async () => {
117
- const failedHosts = [{ id: 301 }, { id: 302 }];
118
- useAPI.mockReturnValue({
119
- response: { results: failedHosts },
120
- status: 'success',
121
- });
122
142
  render(
123
143
  <Provider store={store}>
124
- <CheckboxesActions selectedIds={[]} failedCount={2} jobID={jobID} />
144
+ <CheckboxesActions
145
+ selectedIds={[]}
146
+ jobID={jobID}
147
+ allJobs={allJobs.slice(0, 2)}
148
+ setShowAlert={jest.fn()}
149
+ />
125
150
  </Provider>
126
151
  );
127
152
  fireEvent.click(screen.getByLabelText(/actions dropdown toggle/i));
128
153
  fireEvent.click(await screen.findByText(/open all failed runs/i));
129
154
  await waitFor(() => {
130
- expect(openSpy).toHaveBeenCalledTimes(failedHosts.length);
155
+ expect(openSpy).toHaveBeenCalledTimes(2);
131
156
  });
132
157
  });
133
158
 
134
159
  test('shows modal when results length is greater than 3', async () => {
135
160
  render(
136
161
  <Provider store={store}>
137
- <CheckboxesActions selectedIds={[]} failedCount={4} jobID={jobID} />
162
+ <CheckboxesActions
163
+ selectedIds={[]}
164
+ jobID={jobID}
165
+ allJobs={allJobs}
166
+ setShowAlert={jest.fn()}
167
+ />
138
168
  </Provider>
139
169
  );
140
170
  fireEvent.click(screen.getByLabelText(/actions dropdown toggle/i));
@@ -145,21 +175,6 @@ describe('TableToolbarActions', () => {
145
175
  })
146
176
  ).toBeInTheDocument();
147
177
  });
148
-
149
- test('calls useApi with skip: true when failedCount is 0', () => {
150
- render(
151
- <Provider store={store}>
152
- <CheckboxesActions selectedIds={[]} failedCount={0} jobID={jobID} />
153
- </Provider>
154
- );
155
- expect(useAPI).toHaveBeenCalledWith(
156
- 'get',
157
- foremanUrl(`/api/job_invocations/${jobID}/hosts`),
158
- expect.objectContaining({
159
- skip: true,
160
- })
161
- );
162
- });
163
178
  });
164
179
 
165
180
  describe('PopupAlert', () => {
@@ -188,8 +203,9 @@ describe('TableToolbarActions', () => {
188
203
  <CheckboxesActions
189
204
  bulkParams="(id ^ (101, 102, 103))"
190
205
  selectedIds={selectedIds}
191
- failedCount={1}
192
206
  jobID={jobID}
207
+ allJobs={allJobs}
208
+ setShowAlert={jest.fn()}
193
209
  />
194
210
  </Provider>
195
211
  );
@@ -35,12 +35,12 @@ const JobInvocationDetailPage = ({
35
35
  match: {
36
36
  params: { id },
37
37
  },
38
+ history,
38
39
  }) => {
39
40
  const dispatch = useDispatch();
40
41
  const items = useSelector(selectItems);
41
42
  const {
42
43
  description,
43
- failed = 0,
44
44
  status_label: statusLabel,
45
45
  task,
46
46
  start_at: startAt,
@@ -95,6 +95,16 @@ const JobInvocationDetailPage = ({
95
95
  : STATUS_UPPERCASE.RESOLVED;
96
96
 
97
97
  const breadcrumbOptions = {
98
+ isSwitchable: true,
99
+ onSwitcherItemClick: (e, href) => {
100
+ e.preventDefault();
101
+ history.push(href);
102
+ },
103
+ resource: {
104
+ nameField: 'description',
105
+ resourceUrl: '/api/v2/job_invocations',
106
+ switcherItemUrl: '/job_invocations/:id',
107
+ },
98
108
  breadcrumbItems: [
99
109
  { caption: __('Jobs'), url: `/job_invocations` },
100
110
  {
@@ -182,7 +192,7 @@ const JobInvocationDetailPage = ({
182
192
  <JobInvocationHostTable
183
193
  id={id}
184
194
  targeting={targeting}
185
- failedCount={failed}
195
+ finished={finished}
186
196
  autoRefresh={autoRefresh}
187
197
  initialFilter={selectedFilter}
188
198
  statusLabel={statusLabel}
@@ -200,6 +210,7 @@ JobInvocationDetailPage.propTypes = {
200
210
  id: PropTypes.string.isRequired,
201
211
  }),
202
212
  }).isRequired,
213
+ history: PropTypes.object.isRequired,
203
214
  };
204
215
 
205
216
  export default JobInvocationDetailPage;
@@ -2,32 +2,18 @@ import React from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
 
4
4
  import { translate as __ } from 'foremanReact/common/I18n';
5
+ import LabelIcon from 'foremanReact/components/common/LabelIcon';
5
6
 
6
- import { FormGroup, TextInput, Popover, Icon } from '@patternfly/react-core';
7
-
8
- import { HelpIcon } from '@patternfly/react-icons';
7
+ import { FormGroup, TextInput } from '@patternfly/react-core';
9
8
 
10
9
  const RexInterface = ({ isLoading, onChange }) => (
11
10
  <FormGroup
12
11
  label={__('Remote Execution Interface')}
13
12
  fieldId="reg_rex_interface"
14
13
  labelIcon={
15
- <Popover
16
- bodyContent={
17
- <div>
18
- {__('Identifier of the Host interface for Remote execution')}
19
- </div>
20
- }
21
- >
22
- <button
23
- className="pf-v5-cform__group-label-help"
24
- onClick={e => e.preventDefault()}
25
- >
26
- <Icon isInline>
27
- <HelpIcon />
28
- </Icon>
29
- </button>
30
- </Popover>
14
+ <LabelIcon
15
+ text={__('Identifier of the Host interface for Remote execution')}
16
+ />
31
17
  }
32
18
  >
33
19
  <TextInput
@@ -1,9 +1,25 @@
1
- import { testComponentSnapshotsWithFixtures } from '@theforeman/test';
1
+ import React from 'react';
2
+ import { screen, fireEvent, render, act } from '@testing-library/react';
3
+ import '@testing-library/jest-dom/extend-expect';
2
4
  import RexInterface from '../RexInterface';
3
5
 
4
6
  const fixtures = {
5
7
  renders: { isLoading: false, onChange: () => {} },
6
8
  };
7
9
 
8
- describe('RexInterface', () =>
9
- testComponentSnapshotsWithFixtures(RexInterface, fixtures));
10
+ describe('RexInterface', () => {
11
+ it('should render label with help icon and popover instructions', async () => {
12
+ jest.useFakeTimers();
13
+ render(<RexInterface {...fixtures.renders} />);
14
+ await act(async () => {
15
+ await fireEvent.click(screen.getByRole('button'));
16
+
17
+ jest.advanceTimersByTime(500);
18
+ });
19
+
20
+ expect(screen.getByText('Remote Execution Interface')).toBeInTheDocument();
21
+ expect(
22
+ screen.getByText('Identifier of the Host interface for Remote execution')
23
+ ).toBeInTheDocument();
24
+ });
25
+ });
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.2.1
4
+ version: 16.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Remote Execution team
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-09-16 00:00:00.000000000 Z
10
+ date: 2025-10-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: deface
@@ -551,7 +551,6 @@ files:
551
551
  - webpack/react_app/components/RegistrationExtension/RexInterface.js
552
552
  - webpack/react_app/components/RegistrationExtension/RexPull.js
553
553
  - webpack/react_app/components/RegistrationExtension/__tests__/RexInterface.test.js
554
- - webpack/react_app/components/RegistrationExtension/__tests__/__snapshots__/RexInterface.test.js.snap
555
554
  - webpack/react_app/components/TargetingHosts/TargetingHosts.js
556
555
  - webpack/react_app/components/TargetingHosts/TargetingHostsConsts.js
557
556
  - webpack/react_app/components/TargetingHosts/TargetingHostsHelpers.js
@@ -1,36 +0,0 @@
1
- // Jest Snapshot v1, https://goo.gl/fbAQLP
2
-
3
- exports[`RexInterface renders 1`] = `
4
- <FormGroup
5
- fieldId="reg_rex_interface"
6
- label="Remote Execution Interface"
7
- labelIcon={
8
- <Popover
9
- bodyContent={
10
- <div>
11
- Identifier of the Host interface for Remote execution
12
- </div>
13
- }
14
- >
15
- <button
16
- className="pf-v5-cform__group-label-help"
17
- onClick={[Function]}
18
- >
19
- <Icon
20
- isInline={true}
21
- >
22
- <HelpIcon />
23
- </Icon>
24
- </button>
25
- </Popover>
26
- }
27
- >
28
- <TextInput
29
- id="reg_rex_interface_input"
30
- isDisabled={false}
31
- onBlur={[Function]}
32
- ouiaId="reg_rex_interface_input"
33
- type="text"
34
- />
35
- </FormGroup>
36
- `;