foreman_leapp 3.3.0 → 3.4.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: 8841245ebb39f45ba92ea5b955a3398434dc103a860c50f2930019c0ee102a4e
4
- data.tar.gz: 9a13e0df52eb412eecff7dfa1adff514670ada9dec259a9e349eef1b5ec9e092
3
+ metadata.gz: a7e5030bef562d53e8f4212fa76732f0901ec4b827620a5f07028fd11bcf36d4
4
+ data.tar.gz: eac05d03ce3b746a996751d2b5affb5ea1fabea4d74dd8899f5771b5bc69f8ec
5
5
  SHA512:
6
- metadata.gz: 37b092dbdd94f413763b9301c29c552a55cc44a0a0881462fe228d0f1b3510f750145b20eb55b459bc6acf09212b40381657749eaf0590a49024dbd79c9b097a
7
- data.tar.gz: f93aca5a49a5c5f76fc36759310c0372f7e56b418ee2bad597461700ba9055544c9bdcb0b5ffab9561eddf7121dc0ad80af0061ee5557944f2d71b2af2927be2
6
+ metadata.gz: 77e129433f0175d3951ccc870e66bda4f37930d888a83704142e80d95490e7a9c76b134ab3337c293e03c15bc9c770e8835ba9bec8e11e32d66087ff0a23a5a4
7
+ data.tar.gz: 29c9135b5faf4f6564c71d24cc989a6adc3d4a67c731a5bfca38d3618ec75745f9f1492295356386a7204971d53853558bf5a6d335f5252f55bc0d557bb42f7d
data/Rakefile CHANGED
@@ -6,21 +6,6 @@ begin
6
6
  rescue LoadError
7
7
  puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
8
8
  end
9
- begin
10
- require 'rdoc/task'
11
- rescue LoadError
12
- require 'rdoc/rdoc'
13
- require 'rake/rdoctask'
14
- RDoc::Task = Rake::RDocTask
15
- end
16
-
17
- RDoc::Task.new(:rdoc) do |rdoc|
18
- rdoc.rdoc_dir = 'rdoc'
19
- rdoc.title = 'ForemanLeapp'
20
- rdoc.options << '--line-numbers'
21
- rdoc.rdoc_files.include('README.rdoc')
22
- rdoc.rdoc_files.include('lib/**/*.rb')
23
- end
24
9
 
25
10
  APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
26
11
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ForemanLeapp
4
- VERSION = '3.3.0'
4
+ VERSION = '3.4.0'
5
5
  end
@@ -3,3 +3,12 @@
3
3
  white-space: pre-wrap;
4
4
  }
5
5
  }
6
+
7
+ .leapp-expanded-tbody {
8
+ border: 1px solid var(--pf-v5-global--BorderColor--100);
9
+ box-shadow: var(--pf-v5-global--BoxShadow--sm);
10
+
11
+ tr:first-child > td {
12
+ background-color: var(--pf-v5-global--primary-color--light-background, #e7f1fa);
13
+ }
14
+ }
@@ -1,17 +1,19 @@
1
- import React from 'react';
1
+ import '@testing-library/jest-dom/extend-expect';
2
+
2
3
  import {
4
+ fireEvent,
3
5
  render,
4
6
  screen,
5
7
  waitFor,
6
- fireEvent,
7
8
  within,
8
9
  } from '@testing-library/react';
9
- import '@testing-library/jest-dom/extend-expect';
10
+
11
+ import { APIActions } from 'foremanReact/redux/API';
12
+ import PreupgradeReportsTable from '../index';
10
13
  import { Provider } from 'react-redux';
14
+ import React from 'react';
11
15
  import configureMockStore from 'redux-mock-store';
12
16
  import thunk from 'redux-thunk';
13
- import { APIActions } from 'foremanReact/redux/API';
14
- import PreupgradeReportsTable from '../index';
15
17
 
16
18
  jest.mock('foremanReact/redux/API');
17
19
 
@@ -24,17 +26,25 @@ const mockJobData = {
24
26
  template_name: 'Run preupgrade via Leapp',
25
27
  };
26
28
 
29
+ // Entry 0 (id=1): command remediation + inhibitor flag → fixable + selectable
30
+ // Entry 1 (id=2): hint-only remediation → has_remediation=Yes, NOT selectable
31
+ // Entries 2-11: no remediations → has_remediation=No, NOT selectable
27
32
  const mockEntries = Array.from({ length: 12 }, (_, i) => ({
28
33
  id: i + 1,
29
34
  title: `Report Entry ${i + 1}`,
30
35
  hostname: 'example.com',
36
+ host_id: 100 + i,
31
37
  severity: i === 0 ? 'high' : 'low',
32
38
  summary: `Summary for report entry ${i + 1}`,
33
39
  tags: i === 0 ? ['security', 'network'] : [],
34
40
  flags: i === 0 ? ['inhibitor'] : [],
35
41
  detail: {
36
42
  remediations:
37
- i === 0 ? [{ type: 'command', context: ['echo', 'fix_command'] }] : [],
43
+ i === 0
44
+ ? [{ type: 'command', context: ['echo', 'fix_command'] }]
45
+ : i === 1
46
+ ? [{ type: 'hint', context: 'Do something manually' }]
47
+ : null,
38
48
  external:
39
49
  i === 0 ? [{ url: 'http://example.com', title: 'External Link' }] : [],
40
50
  },
@@ -59,6 +69,8 @@ describe('PreupgradeReportsTable', () => {
59
69
  return { type: 'MOCK_API_SUCCESS' };
60
70
  };
61
71
  });
72
+
73
+ APIActions.post.mockImplementation(() => () => ({ type: 'MOCK_API_POST' }));
62
74
  });
63
75
 
64
76
  const renderComponent = (data = mockJobData) =>
@@ -68,41 +80,106 @@ describe('PreupgradeReportsTable', () => {
68
80
  </Provider>
69
81
  );
70
82
 
71
- const expandSection = () => {
83
+ const expandSection = () =>
72
84
  fireEvent.click(screen.getByText('Leapp preupgrade report'));
73
- };
85
+
86
+ const waitForTable = () =>
87
+ waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
74
88
 
75
89
  it('renders data', async () => {
76
90
  renderComponent();
77
91
  expandSection();
78
- await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
92
+ await waitForTable();
79
93
  expect(
80
94
  screen.getByText('Report Entry 1', { selector: 'td' })
81
95
  ).toBeInTheDocument();
82
96
  });
83
97
 
84
- it('expands a row and shows details', async () => {
85
- renderComponent();
98
+ it('does not render anything for non-Leapp jobs', () => {
99
+ renderComponent({ id: 55, template_name: 'Standard RHEL Update' });
100
+ expect(
101
+ screen.queryByText('Leapp preupgrade report')
102
+ ).not.toBeInTheDocument();
103
+ });
104
+
105
+ it('refetches when status_label transitions (e.g. Running → Succeeded)', async () => {
106
+ const { rerender } = render(
107
+ <Provider store={store}>
108
+ <PreupgradeReportsTable
109
+ data={{ ...mockJobData, status_label: 'Running' }}
110
+ />
111
+ </Provider>
112
+ );
86
113
  expandSection();
87
- await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
114
+ await waitForTable();
88
115
 
89
- const rowExpandButtons = screen.getAllByLabelText('Details');
90
- fireEvent.click(rowExpandButtons[0]);
116
+ const callCountAfterFirstFetch = APIActions.get.mock.calls.length;
91
117
 
92
- expect(await screen.findByText('Summary')).toBeInTheDocument();
118
+ rerender(
119
+ <Provider store={store}>
120
+ <PreupgradeReportsTable
121
+ data={{ ...mockJobData, status_label: 'Succeeded' }}
122
+ />
123
+ </Provider>
124
+ );
125
+
126
+ await waitFor(() =>
127
+ expect(APIActions.get.mock.calls.length).toBeGreaterThan(
128
+ callCountAfterFirstFetch
129
+ )
130
+ );
93
131
  expect(
94
- await screen.findByText('Summary for report entry 1')
132
+ screen.getByText('Report Entry 1', { selector: 'td' })
95
133
  ).toBeInTheDocument();
96
134
  });
97
135
 
98
- it('expands all rows', async () => {
136
+ it('does not refetch on collapse/re-expand when status_label is unchanged', async () => {
137
+ renderComponent({ ...mockJobData, status_label: 'Succeeded' });
138
+ expandSection();
139
+ await waitForTable();
140
+
141
+ const callCountAfterFirstFetch = APIActions.get.mock.calls.length;
142
+
143
+ fireEvent.click(screen.getByText('Leapp preupgrade report')); // collapse
144
+ fireEvent.click(screen.getByText('Leapp preupgrade report')); // re-expand
145
+
146
+ expect(APIActions.get.mock.calls.length).toBe(callCountAfterFirstFetch);
147
+ });
148
+
149
+ it('renders empty state message when no issues found', async () => {
150
+ APIActions.get.mockImplementation(({ key, handleSuccess }) => {
151
+ return () => {
152
+ if (key.includes('GET_LEAPP_REPORT_LIST'))
153
+ handleSuccess({ results: [{ id: mockReportId }] });
154
+ if (key.includes('GET_LEAPP_REPORT_DETAIL'))
155
+ handleSuccess({ id: mockReportId, preupgrade_report_entries: [] });
156
+ return { type: 'EMPTY' };
157
+ };
158
+ });
99
159
  renderComponent();
100
160
  expandSection();
101
- await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
161
+ await waitFor(() =>
162
+ expect(
163
+ screen.getByText('The preupgrade report shows no issues.')
164
+ ).toBeInTheDocument()
165
+ );
166
+ });
102
167
 
103
- const expandAllButton = screen.getByLabelText('Expand all rows');
104
- fireEvent.click(expandAllButton);
168
+ it('expands a row and shows details', async () => {
169
+ renderComponent();
170
+ expandSection();
171
+ await waitForTable();
172
+ fireEvent.click(screen.getAllByLabelText('Details')[0]);
173
+ expect(
174
+ await screen.findByText('Summary for report entry 1')
175
+ ).toBeInTheDocument();
176
+ });
105
177
 
178
+ it('expands all rows', async () => {
179
+ renderComponent();
180
+ expandSection();
181
+ await waitForTable();
182
+ fireEvent.click(screen.getByLabelText('Expand all rows'));
106
183
  expect(
107
184
  await screen.findByText('Summary for report entry 1')
108
185
  ).toBeInTheDocument();
@@ -114,11 +191,9 @@ describe('PreupgradeReportsTable', () => {
114
191
  it('paginates to the next page', async () => {
115
192
  renderComponent();
116
193
  expandSection();
117
- await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
118
-
194
+ await waitForTable();
119
195
  fireEvent.click(screen.getAllByLabelText('Go to next page')[0]);
120
196
  await waitFor(() => screen.getByText('Report Entry 6', { selector: 'td' }));
121
-
122
197
  expect(
123
198
  screen.getByText('Report Entry 10', { selector: 'td' })
124
199
  ).toBeInTheDocument();
@@ -127,19 +202,168 @@ describe('PreupgradeReportsTable', () => {
127
202
  it('changes perPage limit to 10', async () => {
128
203
  renderComponent();
129
204
  expandSection();
130
- await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
131
-
205
+ await waitForTable();
132
206
  fireEvent.click(screen.getAllByLabelText('Items per page')[0]);
133
207
  fireEvent.click(screen.getAllByText('10 per page')[0]);
134
-
135
- await waitFor(() => {
208
+ await waitFor(() =>
136
209
  expect(
137
210
  screen.getByText('Report Entry 10', { selector: 'td' })
138
- ).toBeInTheDocument();
139
- });
211
+ ).toBeInTheDocument()
212
+ );
140
213
  });
141
214
 
142
- it('renders empty state message when no issues found', async () => {
215
+ it('displays correct inhibitor status based on flags', async () => {
216
+ renderComponent();
217
+ expandSection();
218
+ await waitForTable();
219
+
220
+ const row1 = screen
221
+ .getByText('Report Entry 1', { selector: 'td' })
222
+ .closest('tr');
223
+ expect(
224
+ within(row1.querySelector('td[data-label="Inhibitor?"]')).getByText('Yes')
225
+ ).toBeInTheDocument();
226
+
227
+ const row2 = screen
228
+ .getByText('Report Entry 2', { selector: 'td' })
229
+ .closest('tr');
230
+ expect(
231
+ within(row2.querySelector('td[data-label="Inhibitor?"]')).getByText('No')
232
+ ).toBeInTheDocument();
233
+ });
234
+
235
+ it('shows Has Remediation? Yes for any remediation type, not only command', async () => {
236
+ renderComponent();
237
+ expandSection();
238
+ await waitForTable();
239
+
240
+ // id=1: command → Yes
241
+ const row1 = screen
242
+ .getByText('Report Entry 1', { selector: 'td' })
243
+ .closest('tr');
244
+ expect(
245
+ within(row1.querySelector('td[data-label="Has Remediation?"]')).getByText(
246
+ 'Yes'
247
+ )
248
+ ).toBeInTheDocument();
249
+
250
+ // id=2: hint-only → still Yes (display column shows any remediations)
251
+ const row2 = screen
252
+ .getByText('Report Entry 2', { selector: 'td' })
253
+ .closest('tr');
254
+ expect(
255
+ within(row2.querySelector('td[data-label="Has Remediation?"]')).getByText(
256
+ 'Yes'
257
+ )
258
+ ).toBeInTheDocument();
259
+
260
+ // id=3: no remediations → No
261
+ const row3 = screen
262
+ .getByText('Report Entry 3', { selector: 'td' })
263
+ .closest('tr');
264
+ expect(
265
+ within(row3.querySelector('td[data-label="Has Remediation?"]')).getByText(
266
+ 'No'
267
+ )
268
+ ).toBeInTheDocument();
269
+ });
270
+
271
+ it('renders Fix Selected button disabled initially', async () => {
272
+ renderComponent();
273
+ expandSection();
274
+ await waitForTable();
275
+ expect(screen.getByRole('button', { name: 'Fix Selected' })).toBeDisabled();
276
+ });
277
+
278
+ it('enables Fix Selected after selecting a fixable (command) row', async () => {
279
+ renderComponent();
280
+ expandSection();
281
+ await waitForTable();
282
+
283
+ // checkboxes[0] = SelectAll, checkboxes[1] = entry id=1 (command remediation)
284
+ fireEvent.click(screen.getAllByRole('checkbox')[1]);
285
+
286
+ await waitFor(() =>
287
+ expect(
288
+ screen.getByRole('button', { name: 'Fix Selected' })
289
+ ).not.toBeDisabled()
290
+ );
291
+ });
292
+
293
+ it('Fix Selected dispatches APIActions.post with correct feature, host_ids and remediation_ids', async () => {
294
+ renderComponent();
295
+ expandSection();
296
+ await waitForTable();
297
+
298
+ fireEvent.click(screen.getAllByRole('checkbox')[1]); // select entry id=1 (host_id=100)
299
+
300
+ await waitFor(() =>
301
+ expect(
302
+ screen.getByRole('button', { name: 'Fix Selected' })
303
+ ).not.toBeDisabled()
304
+ );
305
+
306
+ fireEvent.click(screen.getByRole('button', { name: 'Fix Selected' }));
307
+
308
+ expect(APIActions.post).toHaveBeenCalledWith(
309
+ expect.objectContaining({
310
+ url: expect.stringContaining('/api/job_invocations'),
311
+ params: {
312
+ job_invocation: {
313
+ feature: 'leapp_remediation_plan',
314
+ host_ids: [100],
315
+ inputs: { remediation_ids: '1' },
316
+ },
317
+ },
318
+ })
319
+ );
320
+ });
321
+
322
+ it('hint-only row checkbox is disabled and does not enable Fix Selected', async () => {
323
+ renderComponent();
324
+ expandSection();
325
+ await waitForTable();
326
+
327
+ // checkboxes[2] = entry id=2, which has hint-only remediation — must be disabled
328
+ const hintCheckbox = screen.getAllByRole('checkbox')[2];
329
+ expect(hintCheckbox).toBeDisabled();
330
+
331
+ fireEvent.click(hintCheckbox);
332
+ expect(screen.getByRole('button', { name: 'Fix Selected' })).toBeDisabled();
333
+ });
334
+
335
+ it('renders Run Upgrade button enabled when entries are present', async () => {
336
+ renderComponent();
337
+ expandSection();
338
+ await waitForTable();
339
+ expect(
340
+ screen.getByRole('button', { name: 'Run Upgrade' })
341
+ ).not.toBeDisabled();
342
+ });
343
+
344
+ it('Run Upgrade dispatches APIActions.post with correct feature and all host_ids', async () => {
345
+ renderComponent();
346
+ expandSection();
347
+ await waitForTable();
348
+
349
+ fireEvent.click(screen.getByRole('button', { name: 'Run Upgrade' }));
350
+
351
+ expect(APIActions.post).toHaveBeenCalledWith(
352
+ expect.objectContaining({
353
+ url: expect.stringContaining('/api/job_invocations'),
354
+ params: {
355
+ job_invocation: {
356
+ feature: 'leapp_upgrade',
357
+ host_ids: expect.arrayContaining([100, 101, 102]),
358
+ },
359
+ },
360
+ })
361
+ );
362
+ const callParams = APIActions.post.mock.calls[0][0].params.job_invocation;
363
+ expect(callParams.inputs).toBeUndefined();
364
+ });
365
+
366
+ it('does not render toolbar buttons when report has no entries', async () => {
143
367
  APIActions.get.mockImplementation(({ key, handleSuccess }) => {
144
368
  return () => {
145
369
  if (key.includes('GET_LEAPP_REPORT_LIST'))
@@ -151,36 +375,35 @@ describe('PreupgradeReportsTable', () => {
151
375
  });
152
376
  renderComponent();
153
377
  expandSection();
154
- await waitFor(() => {
155
- expect(
156
- screen.getByText('The preupgrade report shows no issues.')
157
- ).toBeInTheDocument();
158
- });
159
- });
160
-
161
- it('does not render anything for non-Leapp jobs', () => {
162
- const nonLeappData = { id: 55, template_name: 'Standard RHEL Update' };
163
- renderComponent(nonLeappData);
378
+ await waitFor(() =>
379
+ screen.getByText('The preupgrade report shows no issues.')
380
+ );
164
381
  expect(
165
- screen.queryByText('Leapp preupgrade report')
382
+ screen.queryByRole('button', { name: 'Fix Selected' })
383
+ ).not.toBeInTheDocument();
384
+ expect(
385
+ screen.queryByRole('button', { name: 'Run Upgrade' })
166
386
  ).not.toBeInTheDocument();
167
387
  });
168
388
 
169
- it('displays correct inhibitor status based on flags', async () => {
389
+ it('renders the SelectAll checkbox', async () => {
170
390
  renderComponent();
171
391
  expandSection();
172
- await waitFor(() => screen.getByText('Report Entry 1', { selector: 'td' }));
392
+ await waitForTable();
393
+ expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
394
+ });
173
395
 
174
- const row1 = screen
175
- .getByText('Report Entry 1', { selector: 'td' })
176
- .closest('tr');
177
- const inhibitorCell1 = row1.querySelector('td[data-label="Inhibitor?"]');
178
- expect(within(inhibitorCell1).getByText('Yes')).toBeInTheDocument();
396
+ it('selecting all fixable rows enables Fix Selected', async () => {
397
+ renderComponent();
398
+ expandSection();
399
+ await waitForTable();
179
400
 
180
- const row2 = screen
181
- .getByText('Report Entry 2', { selector: 'td' })
182
- .closest('tr');
183
- const inhibitorCell2 = row2.querySelector('td[data-label="Inhibitor?"]');
184
- expect(within(inhibitorCell2).getByText('No')).toBeInTheDocument();
401
+ fireEvent.click(screen.getByLabelText('Select all'));
402
+
403
+ await waitFor(() =>
404
+ expect(
405
+ screen.getByRole('button', { name: 'Fix Selected' })
406
+ ).not.toBeDisabled()
407
+ );
185
408
  });
186
409
  });
@@ -1,163 +1,374 @@
1
+ /* eslint-disable max-lines */
1
2
  import PropTypes from 'prop-types';
2
- import React, { useEffect, useState } from 'react';
3
+ import React, {
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
3
10
  import { useDispatch } from 'react-redux';
4
- import { ExpandableSection, Tooltip } from '@patternfly/react-core';
11
+ import {
12
+ Button,
13
+ ExpandableSection,
14
+ Toolbar,
15
+ ToolbarContent,
16
+ ToolbarGroup,
17
+ ToolbarItem,
18
+ Tooltip,
19
+ } from '@patternfly/react-core';
5
20
  import { ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table';
6
21
  import { translate as __ } from 'foremanReact/common/I18n';
22
+ import { foremanUrl } from 'foremanReact/common/helpers';
7
23
  import { Table } from 'foremanReact/components/PF4/TableIndexPage/Table/Table';
24
+ import SelectAllCheckbox from 'foremanReact/components/PF4/TableIndexPage/Table/SelectAllCheckbox';
25
+ import { useBulkSelect } from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks';
26
+ import { RowSelectTd } from 'foremanReact/components/PF4/TableIndexPage/RowSelectTd';
8
27
  import { getColumnHelpers } from 'foremanReact/components/PF4/TableIndexPage/Table/helpers';
9
28
  import { APIActions } from 'foremanReact/redux/API';
10
29
  import { STATUS } from 'foremanReact/constants';
11
- import { entriesPage } from '../PreupgradeReports/PreupgradeReportsHelpers';
30
+ import {
31
+ entriesPage,
32
+ entryFixable,
33
+ } from '../PreupgradeReports/PreupgradeReportsHelpers';
12
34
  import ReportDetails, { renderSeverityLabel } from './ReportDetails';
35
+ import './PreupgradeReportsTable.scss';
36
+
37
+ const LEAPP_TEMPLATE_NAME = 'Run preupgrade via Leapp';
38
+
39
+ const isRowFixable = entryFixable;
40
+
41
+ const submitJobInvocation = (
42
+ dispatch,
43
+ setError,
44
+ feature,
45
+ hostIds,
46
+ remediationIds
47
+ ) => {
48
+ const payload = {
49
+ job_invocation: {
50
+ feature,
51
+ host_ids: hostIds,
52
+ ...(remediationIds != null
53
+ ? { inputs: { remediation_ids: remediationIds } }
54
+ : {}),
55
+ },
56
+ };
57
+
58
+ dispatch(
59
+ APIActions.post({
60
+ key: `CREATE_JOB_INVOCATION_${feature}`,
61
+ url: foremanUrl('/api/job_invocations'),
62
+ params: payload,
63
+ handleSuccess: response => {
64
+ const result = response.data || response;
65
+ if (result?.id) {
66
+ window.location.assign(foremanUrl(`/job_invocations/${result.id}`));
67
+ }
68
+ },
69
+ handleError: err => setError(err),
70
+ })
71
+ );
72
+ };
13
73
 
14
74
  const PreupgradeReportsTable = ({ data = {} }) => {
15
75
  const [error, setError] = useState(null);
16
-
17
- const [isReportExpanded, setIsReportExpanded] = useState(false); // Outer expansion state (Leapp Report Section)
76
+ const [isReportExpanded, setIsReportExpanded] = useState(false);
18
77
  const [pagination, setPagination] = useState({ page: 1, perPage: 5 });
19
78
  const [reportData, setReportData] = useState(null);
20
79
  const [status, setStatus] = useState(STATUS.RESOLVED);
21
- const [expandedRowIds, setExpandedRowIds] = useState(new Set()); // Inner table expansion state (Rows)
80
+ const [expandedRowIds, setExpandedRowIds] = useState(new Set());
22
81
 
23
82
  const dispatch = useDispatch();
24
83
  // eslint-disable-next-line camelcase
25
- const isLeappJob = data?.template_name?.includes('Run preupgrade via Leapp');
84
+ const isLeappJob = data?.template_name?.includes(LEAPP_TEMPLATE_NAME);
26
85
 
27
- const columns = {
28
- title: {
29
- title: __('Title'),
30
- },
31
- host: {
32
- title: __('Host'),
33
- wrapper: entry =>
34
- entry.hostname || (reportData && reportData.hostname) || '-',
35
- },
36
- risk_factor: {
37
- title: __('Risk Factor'),
38
- wrapper: ({ severity }) => renderSeverityLabel(severity),
39
- },
40
- has_remediation: {
41
- title: __('Has Remediation?'),
42
- wrapper: entry =>
43
- entry.detail && entry.detail.remediations ? __('Yes') : __('No'),
44
- },
45
- inhibitor: {
46
- title: __('Inhibitor?'),
47
- wrapper: entry =>
48
- entry.flags && entry.flags.some(flag => flag === 'inhibitor') ? (
49
- <Tooltip content={__('This issue inhibits the upgrade.')}>
50
- <span>{__('Yes')}</span>
51
- </Tooltip>
52
- ) : (
53
- __('No')
54
- ),
55
- },
56
- };
86
+ // eslint-disable-next-line camelcase
87
+ const jobStatusLabel = data?.status_label;
88
+
89
+ const lastFetchedKeyRef = useRef(null);
90
+
91
+ const columns = useMemo(
92
+ () => ({
93
+ title: { title: __('Title') },
94
+ host: {
95
+ title: __('Host'),
96
+ wrapper: entry => entry.hostname || reportData?.hostname || '-',
97
+ },
98
+ risk_factor: {
99
+ title: __('Risk Factor'),
100
+ wrapper: ({ severity }) => renderSeverityLabel(severity),
101
+ },
102
+ has_remediation: {
103
+ title: __('Has Remediation?'),
104
+ wrapper: entry => (entry.detail?.remediations ? __('Yes') : __('No')),
105
+ },
106
+ inhibitor: {
107
+ title: __('Inhibitor?'),
108
+ wrapper: entry =>
109
+ entry.flags?.some(flag => flag === 'inhibitor') ? (
110
+ <Tooltip content={__('This issue inhibits the upgrade.')}>
111
+ <span>{__('Yes')}</span>
112
+ </Tooltip>
113
+ ) : (
114
+ __('No')
115
+ ),
116
+ },
117
+ }),
118
+ // eslint-disable-next-line react-hooks/exhaustive-deps
119
+ [reportData?.hostname]
120
+ );
57
121
 
58
122
  useEffect(() => {
59
123
  let isMounted = true;
60
- if (!isLeappJob || !isReportExpanded || reportData) {
124
+ const fetchKey = `${data.id}:${jobStatusLabel}`;
125
+
126
+ if (
127
+ !isLeappJob ||
128
+ !isReportExpanded ||
129
+ lastFetchedKeyRef.current === fetchKey
130
+ )
61
131
  return undefined;
62
- }
63
- setStatus(STATUS.PENDING);
64
132
 
133
+ const fail = err => {
134
+ if (!isMounted) return;
135
+ setError(err);
136
+ setStatus(STATUS.ERROR);
137
+ };
138
+
139
+ const succeed = response => {
140
+ if (!isMounted) return;
141
+ lastFetchedKeyRef.current = fetchKey;
142
+ setReportData(response?.data || response || null);
143
+ setStatus(STATUS.RESOLVED);
144
+ };
145
+
146
+ setStatus(STATUS.PENDING);
65
147
  dispatch(
66
148
  APIActions.get({
67
149
  key: `GET_LEAPP_REPORT_LIST_${data.id}`,
68
150
  url: `/api/job_invocations/${data.id}/preupgrade_reports`,
69
151
  handleSuccess: listResponse => {
70
152
  if (!isMounted) return;
71
- const listPayload = listResponse.data || listResponse;
72
- const summary = listPayload.results?.[0];
153
+ const summary = (listResponse.data || listResponse).results?.[0];
73
154
  if (summary?.id) {
74
155
  dispatch(
75
156
  APIActions.get({
76
157
  key: `GET_LEAPP_REPORT_DETAIL_${summary.id}`,
77
158
  url: `/api/preupgrade_reports/${summary.id}`,
78
- handleSuccess: detailResponse => {
79
- if (isMounted) {
80
- const detailPayload = detailResponse.data || detailResponse;
81
- setReportData(detailPayload);
82
- setStatus(STATUS.RESOLVED);
83
- }
84
- },
85
- handleError: err => {
86
- if (isMounted) {
87
- setError(err);
88
- setStatus(STATUS.ERROR);
89
- }
90
- },
159
+ handleSuccess: detailResponse => succeed(detailResponse),
160
+ handleError: err => fail(err),
91
161
  })
92
162
  );
93
- } else if (isMounted) {
94
- setReportData({});
95
- setStatus(STATUS.RESOLVED);
96
- }
97
- },
98
- handleError: err => {
99
- if (isMounted) {
100
- setError(err);
101
- setStatus(STATUS.ERROR);
163
+ return;
102
164
  }
165
+ succeed(null);
103
166
  },
167
+ handleError: err => fail(err),
104
168
  })
105
169
  );
106
170
 
107
171
  return () => {
108
172
  isMounted = false;
109
173
  };
110
- }, [isReportExpanded, data.id, isLeappJob, reportData, dispatch]);
174
+ }, [isReportExpanded, data.id, isLeappJob, dispatch, jobStatusLabel]);
111
175
 
112
176
  // eslint-disable-next-line camelcase
113
- const entries = reportData?.preupgrade_report_entries || [];
114
- const pagedEntries = entriesPage(entries, pagination);
177
+ const entries = useMemo(() => reportData?.preupgrade_report_entries || [], [
178
+ reportData,
179
+ ]);
115
180
 
116
- const handleParamsChange = newParams => {
181
+ const pagedEntries = useMemo(
182
+ () => entriesPage(entries, pagination),
183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
184
+ [entries, pagination.page, pagination.perPage]
185
+ );
186
+
187
+ const getHostId = useCallback(
188
+ entry =>
189
+ entry.host_id ||
190
+ entry.hostId ||
191
+ // eslint-disable-next-line camelcase
192
+ reportData?.host_id ||
193
+ reportData?.host?.id ||
194
+ // eslint-disable-next-line camelcase
195
+ data?.targeting?.host_id,
196
+ [reportData, data]
197
+ );
198
+
199
+ const handleParamsChange = useCallback(newParams => {
117
200
  setPagination(prev => ({
118
201
  ...prev,
119
202
  page: newParams.page || prev.page,
120
203
  perPage: newParams.per_page || prev.perPage,
121
204
  }));
122
205
  setExpandedRowIds(new Set());
123
- };
206
+ }, []);
124
207
 
125
- const toggleRowExpansion = (id, isExpanding) => {
208
+ const toggleRowExpansion = useCallback((id, isExpanding) => {
126
209
  setExpandedRowIds(prev => {
127
- const newSet = new Set(prev);
128
- if (isExpanding) {
129
- newSet.add(id);
130
- } else {
131
- newSet.delete(id);
132
- }
133
- return newSet;
210
+ const next = new Set(prev);
211
+ if (isExpanding) next.add(id);
212
+ else next.delete(id);
213
+ return next;
134
214
  });
135
- };
215
+ }, []);
216
+
217
+ const { inclusionSet, exclusionSet, ...selectAllOptions } = useBulkSelect({
218
+ results: pagedEntries,
219
+ metadata: {
220
+ total: entries.length,
221
+ page: pagination.page,
222
+ selectable: entries.length,
223
+ },
224
+ initialSearchQuery: '',
225
+ });
226
+
227
+ const {
228
+ selectAll,
229
+ selectPage,
230
+ selectNone,
231
+ selectOne,
232
+ areAllRowsSelected,
233
+ isSelected,
234
+ } = selectAllOptions;
235
+
236
+ const rawSelectedIds =
237
+ areAllRowsSelected() || exclusionSet.size > 0
238
+ ? entries.map(e => e.id).filter(id => !exclusionSet.has(id))
239
+ : Array.from(inclusionSet);
240
+
241
+ const validFixableIds = useMemo(
242
+ () => entries.filter(isRowFixable).map(e => e.id),
243
+ [entries]
244
+ );
245
+
246
+ // eslint-disable-next-line react-hooks/exhaustive-deps
247
+ const selectedIds = useMemo(
248
+ () => rawSelectedIds.filter(id => validFixableIds.includes(id)),
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ [rawSelectedIds.join(','), validFixableIds]
251
+ );
252
+
253
+ const pagedFixableEntries = useMemo(() => pagedEntries.filter(isRowFixable), [
254
+ pagedEntries,
255
+ ]);
256
+
257
+ const areAllPageFixableSelected =
258
+ pagedFixableEntries.length > 0 &&
259
+ pagedFixableEntries.every(e => selectedIds.includes(e.id));
260
+
261
+ const areAllFixableSelected =
262
+ validFixableIds.length > 0 &&
263
+ validFixableIds.every(id => selectedIds.includes(id));
136
264
 
137
265
  const areAllRowsExpanded =
138
266
  pagedEntries.length > 0 &&
139
267
  pagedEntries.every(entry => expandedRowIds.has(entry.id));
140
268
 
141
- const onExpandAll = () => {
142
- setExpandedRowIds(() => {
143
- if (areAllRowsExpanded) {
144
- return new Set();
145
- }
146
- return new Set(pagedEntries.map(e => e.id));
147
- });
148
- };
269
+ const onExpandAll = useCallback(() => {
270
+ setExpandedRowIds(
271
+ areAllRowsExpanded ? new Set() : new Set(pagedEntries.map(e => e.id))
272
+ );
273
+ }, [areAllRowsExpanded, pagedEntries]);
274
+
275
+ const [columnKeys, keysToColumnNames] = useMemo(
276
+ () => getColumnHelpers(columns),
277
+ [columns]
278
+ );
279
+
280
+ const hostIdsForSelected = useMemo(
281
+ () =>
282
+ Array.from(
283
+ new Set(
284
+ entries
285
+ .filter(e => selectedIds.includes(e.id))
286
+ .map(getHostId)
287
+ .filter(Boolean)
288
+ )
289
+ ),
290
+ [entries, selectedIds, getHostId]
291
+ );
149
292
 
150
- const [columnKeys, keysToColumnNames] = getColumnHelpers(columns);
293
+ const allHostIds = useMemo(
294
+ () => Array.from(new Set(entries.map(getHostId).filter(Boolean))),
295
+ [entries, getHostId]
296
+ );
151
297
 
152
298
  if (!isLeappJob) return null;
153
299
 
300
+ const isFixSelectedDisabled =
301
+ validFixableIds.length === 0 ||
302
+ selectedIds.length === 0 ||
303
+ hostIdsForSelected.length === 0;
304
+
154
305
  return (
155
306
  <ExpandableSection
156
- className="leapp-report-section"
157
307
  isExpanded={isReportExpanded}
158
308
  onToggle={(_event, val) => setIsReportExpanded(val)}
159
309
  toggleText={__('Leapp preupgrade report')}
160
310
  >
311
+ {entries.length > 0 && status === STATUS.RESOLVED && (
312
+ <Toolbar ouiaId="leapp-report-toolbar">
313
+ <ToolbarContent>
314
+ <ToolbarGroup variant="filter-group">
315
+ <ToolbarItem>
316
+ <SelectAllCheckbox
317
+ selectAll={selectAll}
318
+ selectPage={selectPage}
319
+ selectNone={selectNone}
320
+ selectedCount={selectedIds.length}
321
+ pageRowCount={pagedFixableEntries.length}
322
+ totalCount={validFixableIds.length}
323
+ areAllRowsOnPageSelected={areAllPageFixableSelected}
324
+ areAllRowsSelected={areAllFixableSelected}
325
+ />
326
+ </ToolbarItem>
327
+ </ToolbarGroup>
328
+ <ToolbarGroup
329
+ align={{ default: 'alignRight' }}
330
+ variant="button-group"
331
+ >
332
+ <ToolbarItem>
333
+ <Button
334
+ variant="secondary"
335
+ isDisabled={isFixSelectedDisabled}
336
+ onClick={() =>
337
+ submitJobInvocation(
338
+ dispatch,
339
+ setError,
340
+ 'leapp_remediation_plan',
341
+ hostIdsForSelected,
342
+ selectedIds.join(',')
343
+ )
344
+ }
345
+ ouiaId="fix-selected-button"
346
+ >
347
+ {__('Fix Selected')}
348
+ </Button>
349
+ </ToolbarItem>
350
+ <ToolbarItem>
351
+ <Button
352
+ variant="primary"
353
+ isDisabled={allHostIds.length === 0}
354
+ onClick={() =>
355
+ submitJobInvocation(
356
+ dispatch,
357
+ setError,
358
+ 'leapp_upgrade',
359
+ allHostIds
360
+ )
361
+ }
362
+ ouiaId="run-upgrade-button"
363
+ >
364
+ {__('Run Upgrade')}
365
+ </Button>
366
+ </ToolbarItem>
367
+ </ToolbarGroup>
368
+ </ToolbarContent>
369
+ </Toolbar>
370
+ )}
371
+
161
372
  <Table
162
373
  ouiaId="leapp-report-table"
163
374
  columns={columns}
@@ -174,20 +385,24 @@ const PreupgradeReportsTable = ({ data = {} }) => {
174
385
  errorMessage={
175
386
  status === STATUS.ERROR && error?.message ? error.message : null
176
387
  }
177
- showCheckboxes={false}
388
+ showCheckboxes
178
389
  refreshData={() => {}}
179
390
  isDeleteable={false}
180
391
  emptyMessage={__('The preupgrade report shows no issues.')}
181
392
  setParams={handleParamsChange}
182
393
  childrenOutsideTbody
183
394
  onExpandAll={onExpandAll}
184
- // Inverted per PatternFly implementation to ensure correct toggle icon state
185
395
  areAllRowsExpanded={!areAllRowsExpanded}
186
396
  >
187
397
  {pagedEntries.map((entry, rowIndex) => {
188
398
  const isRowExpanded = expandedRowIds.has(entry.id);
399
+
189
400
  return (
190
- <Tbody key={entry.id} isExpanded={isRowExpanded}>
401
+ <Tbody
402
+ key={entry.id}
403
+ isExpanded={isRowExpanded}
404
+ className={isRowExpanded ? 'leapp-expanded-tbody' : ''}
405
+ >
191
406
  <Tr ouiaId={`table-row-${rowIndex}`}>
192
407
  <Td
193
408
  expand={{
@@ -197,6 +412,12 @@ const PreupgradeReportsTable = ({ data = {} }) => {
197
412
  toggleRowExpansion(entry.id, isOpen),
198
413
  }}
199
414
  />
415
+ <RowSelectTd
416
+ rowData={entry}
417
+ selectOne={selectOne}
418
+ isSelected={id => isRowFixable(entry) && isSelected(id)}
419
+ isSelectable={isRowFixable}
420
+ />
200
421
  {columnKeys.map(key => (
201
422
  <Td key={key} dataLabel={keysToColumnNames[key]}>
202
423
  {columns[key].wrapper
@@ -209,7 +430,7 @@ const PreupgradeReportsTable = ({ data = {} }) => {
209
430
  isExpanded={isRowExpanded}
210
431
  ouiaId={`table-row-details-${rowIndex}`}
211
432
  >
212
- <Td colSpan={columnKeys.length + 1}>
433
+ <Td colSpan={columnKeys.length + 2}>
213
434
  <ExpandableRowContent>
214
435
  {isRowExpanded && <ReportDetails entry={entry} />}
215
436
  </ExpandableRowContent>
@@ -226,7 +447,13 @@ const PreupgradeReportsTable = ({ data = {} }) => {
226
447
  PreupgradeReportsTable.propTypes = {
227
448
  data: PropTypes.shape({
228
449
  id: PropTypes.number,
450
+ // eslint-disable-next-line camelcase
229
451
  template_name: PropTypes.string,
452
+ // eslint-disable-next-line camelcase
453
+ status_label: PropTypes.string,
454
+ targeting: PropTypes.shape({
455
+ host_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
456
+ }),
230
457
  }),
231
458
  };
232
459
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_leapp
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.3.0
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foreman Leapp team
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-03-10 00:00:00.000000000 Z
10
+ date: 2026-05-15 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: foreman_remote_execution
@@ -37,20 +37,6 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '5.0'
40
- - !ruby/object:Gem::Dependency
41
- name: rdoc
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '6.2'
47
- type: :development
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '6.2'
54
40
  description: A Foreman plugin to support inplace RHEL upgrades with Leapp utility.
55
41
  email:
56
42
  - foreman-dev@googlegroups.com
@@ -214,7 +200,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
214
200
  - !ruby/object:Gem::Version
215
201
  version: '0'
216
202
  requirements: []
217
- rubygems_version: 4.0.3
203
+ rubygems_version: 4.0.10
218
204
  specification_version: 4
219
205
  summary: A Foreman plugin for Leapp utility.
220
206
  test_files: