foreman_remote_execution 16.3.1 → 16.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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/api/v2/job_invocations_controller.rb +3 -5
  3. data/app/lib/proxy_api/remote_execution_ssh.rb +9 -0
  4. data/app/models/concerns/foreman_remote_execution/host_extensions.rb +12 -4
  5. data/app/models/concerns/foreman_remote_execution/smart_proxy_extensions.rb +14 -0
  6. data/app/views/api/v2/smart_proxies/ca_pubkey.json.rabl +1 -0
  7. data/db/migrate/20250606125543_add_ca_pub_key_to_smart_proxy.rb +5 -0
  8. data/lib/foreman_remote_execution/plugin.rb +1 -0
  9. data/lib/foreman_remote_execution/version.rb +1 -1
  10. data/test/unit/concerns/host_extensions_test.rb +43 -0
  11. data/webpack/JobInvocationDetail/JobInvocationDetail.scss +4 -0
  12. data/webpack/JobInvocationDetail/JobInvocationHostTable.js +216 -129
  13. data/webpack/JobInvocationDetail/TemplateInvocation.js +19 -15
  14. data/webpack/JobInvocationDetail/TemplateInvocationComponents/OutputToggleGroup.js +43 -25
  15. data/webpack/JobInvocationDetail/TemplateInvocationPage.js +16 -1
  16. data/webpack/JobInvocationDetail/__tests__/TemplateInvocation.test.js +114 -72
  17. data/webpack/JobInvocationDetail/index.js +4 -5
  18. data/webpack/JobWizard/JobWizard.js +11 -10
  19. data/webpack/JobWizard/autofill.js +10 -2
  20. data/webpack/JobWizard/steps/AdvancedFields/__tests__/__snapshots__/AdvancedFields.test.js.snap +1 -1
  21. data/webpack/JobWizard/steps/CategoryAndTemplate/index.js +25 -13
  22. data/webpack/JobWizard/steps/HostsAndInputs/SelectAPI.js +16 -11
  23. data/webpack/JobWizard/steps/form/ResourceSelect.js +18 -16
  24. data/webpack/JobWizard/steps/form/SearchSelect.js +6 -7
  25. data/webpack/JobWizard/validation.js +1 -3
  26. data/webpack/react_app/components/TargetingHosts/index.js +49 -32
  27. metadata +5 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1f3605e5f510dbb583d0af3208b3f80f7fdea955420f5d65425d896a37997b3c
4
- data.tar.gz: '092f3cddbb03c09274aaedb3517b4d3cb9aafe2e08399e8361ac1772591d8d5d'
3
+ metadata.gz: 2db78eb6979bd486194e94b58c14978db6d08c02f675a042bd9827c9101b0639
4
+ data.tar.gz: c9060344c28a222b93fed7f26794fde15827030ae7b3b6197a66b9f86413b656
5
5
  SHA512:
6
- metadata.gz: f7295aee226fb5438f923dc74a629e427d01a967cc309404297417dd1b850bec2051350f0a465e26d3345ff707907abf963b4e547be7707095ce0e8d5e33f090
7
- data.tar.gz: 7a3a7d1d9730a007208053c240fba27a1cef39d8121af9c0242c293641b93d77c99c1b1c573674e0a5d9bebcb161263cf7f262260830ff29ec8ffcc8ccd0f406
6
+ metadata.gz: 478f4cc9af2467020b4498bdd757d9f3b33a5e233c65b0b941cb0eda2deacf0689077907298e111c7cc1ec13e64508c9cd33d0870df70fe8a7247dd99e9433a9
7
+ data.tar.gz: f2bf38fe739e5043ba595448090a4231a52ef923464eec41b470d71c8ccdf3f83c8faec3a47627c5a606601d343b42302093a3a22474f57dded90375933a506e
@@ -116,13 +116,11 @@ module Api
116
116
  def hosts
117
117
  set_hosts_and_template_invocations
118
118
  set_statuses_and_smart_proxies
119
- @total = @job_invocation.targeting.hosts.size
119
+ @total = @hosts.size
120
120
  @hosts = @hosts.search_for(params[:search], :order => params[:order]).paginate(:page => params[:page], :per_page => params[:per_page])
121
+ @subtotal = @hosts.total_entries
121
122
  if params[:awaiting]
122
123
  @hosts = @hosts.select { |host| @host_statuses[host.id] == 'N/A' }
123
- @subtotal = @hosts.size
124
- else
125
- @subtotal = @hosts.respond_to?(:total_entries) ? @hosts.total_entries : @hosts.sizes
126
124
  end
127
125
  render :hosts, :layout => 'api/v2/layouts/index_layout'
128
126
  end
@@ -303,7 +301,7 @@ module Api
303
301
  @pattern_template_invocations = @job_invocation.pattern_template_invocations.includes(:input_values)
304
302
  @hosts = @job_invocation.targeting.hosts.authorized(:view_hosts, Host)
305
303
 
306
- unless params[:search].nil?
304
+ if params[:search].present?
307
305
  @hosts = @hosts.joins(:template_invocations)
308
306
  .where(:template_invocations => { :job_invocation_id => @job_invocation.id})
309
307
  end
@@ -11,6 +11,15 @@ module ::ProxyAPI
11
11
  raise ProxyException.new(url, e, N_('Unable to fetch public key'))
12
12
  end
13
13
 
14
+ def ca_pubkey
15
+ get('ca_pubkey')&.strip
16
+ rescue RestClient::ResourceNotFound => e
17
+ Rails.logger.warn(format(N_("Unable to fetch CA public key: %{error}"), error: e.message))
18
+ nil
19
+ rescue => e
20
+ raise ProxyException.new(url, e, N_('Unable to fetch CA public key'))
21
+ end
22
+
14
23
  def drop_from_known_hosts(hostname)
15
24
  delete('known_hosts/' + hostname)
16
25
  rescue => e
@@ -108,7 +108,12 @@ module ForemanRemoteExecution
108
108
  end
109
109
 
110
110
  def remote_execution_ssh_keys
111
- remote_execution_proxies(%w(SSH Script), false).values.flatten.uniq.map { |proxy| proxy.pubkey }.compact.uniq
111
+ # only include public keys from SSH proxies that don't have SSH cert verification configured
112
+ remote_execution_proxies(%w(SSH Script), false).values.flatten.uniq.map { |proxy| proxy.pubkey if proxy.ca_pubkey.blank? }.compact.uniq
113
+ end
114
+
115
+ def remote_execution_ssh_ca_keys
116
+ remote_execution_proxies(%w(SSH Script), false).values.flatten.uniq.map { |proxy| proxy.ca_pubkey }.compact.uniq
112
117
  end
113
118
 
114
119
  def drop_execution_interface_cache
@@ -139,10 +144,13 @@ module ForemanRemoteExecution
139
144
 
140
145
  def extend_host_params_hash(params)
141
146
  keys = remote_execution_ssh_keys
147
+ ca_keys = remote_execution_ssh_ca_keys
142
148
  source = 'global'
143
- if keys.present?
144
- value, safe_value = params.fetch('remote_execution_ssh_keys', {}).values_at(:value, :safe_value).map { |v| [v].flatten.compact }
145
- params['remote_execution_ssh_keys'] = {:value => value + keys, :safe_value => safe_value + keys, :source => source}
149
+ {keys: keys, ca_keys: ca_keys}.each do |key_set_name, key_set|
150
+ if key_set.present?
151
+ value, safe_value = params.fetch("remote_execution_ssh_#{key_set_name}", {}).values_at(:value, :safe_value).map { |v| [v].flatten.compact }
152
+ params["remote_execution_ssh_#{key_set_name}"] = {:value => value + key_set, :safe_value => safe_value + key_set, :source => source}
153
+ end
146
154
  end
147
155
  [:remote_execution_ssh_user, :remote_execution_effective_user_method,
148
156
  :remote_execution_connect_by_ip].each do |key|
@@ -11,6 +11,10 @@ module ForemanRemoteExecution
11
11
  self[:pubkey] || update_pubkey
12
12
  end
13
13
 
14
+ def ca_pubkey
15
+ self[:ca_pubkey] || update_ca_pubkey
16
+ end
17
+
14
18
  def update_pubkey
15
19
  return unless has_feature?(%w(SSH Script))
16
20
 
@@ -19,6 +23,15 @@ module ForemanRemoteExecution
19
23
  key
20
24
  end
21
25
 
26
+ def update_ca_pubkey
27
+ return unless has_feature?(%w(SSH Script))
28
+
29
+ # smart proxy is not required to have a CA pubkey, in which case an empty string is returned
30
+ key = ::ProxyAPI::RemoteExecutionSSH.new(:url => url).ca_pubkey&.presence
31
+ self.update_attribute(:ca_pubkey, key)
32
+ key
33
+ end
34
+
22
35
  def drop_host_from_known_hosts(host)
23
36
  ::ProxyAPI::RemoteExecutionSSH.new(:url => url).drop_from_known_hosts(host)
24
37
  end
@@ -26,6 +39,7 @@ module ForemanRemoteExecution
26
39
  def refresh
27
40
  errors = super
28
41
  update_pubkey
42
+ update_ca_pubkey
29
43
  errors
30
44
  end
31
45
  end
@@ -0,0 +1 @@
1
+ attribute :ca_pubkey => :remote_execution_ca_pubkey
@@ -0,0 +1,5 @@
1
+ class AddCAPubKeyToSmartProxy < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :smart_proxies, :ca_pubkey, :text
4
+ end
5
+ end
@@ -213,6 +213,7 @@ Foreman::Plugin.register :foreman_remote_execution do
213
213
  extend_template_helpers ForemanRemoteExecution::RendererMethods
214
214
 
215
215
  extend_rabl_template 'api/v2/smart_proxies/main', 'api/v2/smart_proxies/pubkey'
216
+ extend_rabl_template 'api/v2/smart_proxies/main', 'api/v2/smart_proxies/ca_pubkey'
216
217
  extend_rabl_template 'api/v2/interfaces/main', 'api/v2/interfaces/execution_flag'
217
218
  extend_rabl_template 'api/v2/subnets/show', 'api/v2/subnets/remote_execution_proxies'
218
219
  extend_rabl_template 'api/v2/hosts/main', 'api/v2/host/main'
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '16.3.1'.freeze
2
+ VERSION = '16.4.0'.freeze
3
3
  end
@@ -12,6 +12,7 @@ class ForemanRemoteExecutionHostExtensionsTest < ActiveSupport::TestCase
12
12
 
13
13
  before do
14
14
  SmartProxy.any_instance.stubs(:pubkey).returns(sshkey)
15
+ SmartProxy.any_instance.stubs(:ca_pubkey).returns(nil)
15
16
  Setting[:remote_execution_ssh_user] = 'root'
16
17
  Setting[:remote_execution_effective_user_method] = 'sudo'
17
18
  end
@@ -61,6 +62,48 @@ class ForemanRemoteExecutionHostExtensionsTest < ActiveSupport::TestCase
61
62
  end
62
63
  end
63
64
 
65
+ describe 'has ssh CA key configured' do
66
+ let(:host) { FactoryBot.create(:host, :with_execution) }
67
+ let(:sshkey) { 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQ foo@example.com' }
68
+ let(:ca_sshkey) { 'ssh-rsa AAAAB3NzaC1yc2EAAAABJE bar@example.com' }
69
+
70
+ before do
71
+ SmartProxy.any_instance.stubs(:pubkey).returns(sshkey)
72
+ SmartProxy.any_instance.stubs(:ca_pubkey).returns(ca_sshkey)
73
+ Setting[:remote_execution_ssh_user] = 'root'
74
+ Setting[:remote_execution_effective_user_method] = 'sudo'
75
+ end
76
+
77
+ it 'has CA ssh keys in the parameters' do
78
+ assert_includes host.remote_execution_ssh_ca_keys, ca_sshkey
79
+ end
80
+
81
+ it 'excludes ssh keys from proxies that have SSH CA key configured' do
82
+ assert_empty host.remote_execution_ssh_keys
83
+ end
84
+
85
+ it 'merges ssh CA keys from host parameters and proxies' do
86
+ key = 'ssh-rsa not-even-a-key something@somewhere.com'
87
+ host.host_parameters << FactoryBot.create(:host_parameter, :host => host, :name => 'remote_execution_ssh_ca_keys', :value => [key])
88
+ assert_includes host.host_param('remote_execution_ssh_ca_keys'), key
89
+ assert_includes host.host_param('remote_execution_ssh_ca_keys'), ca_sshkey
90
+ end
91
+
92
+ it 'has ssh CA keys in the parameters even when no user specified' do
93
+ FactoryBot.create(:smart_proxy, :ssh)
94
+ host.interfaces.first.subnet.remote_execution_proxies.clear
95
+ User.current = nil
96
+ assert_includes host.remote_execution_ssh_ca_keys, ca_sshkey
97
+ end
98
+
99
+ it 'merges ssh CA key as a string from host parameters and proxies' do
100
+ key = 'ssh-rsa not-even-a-key something@somewhere.com'
101
+ host.host_parameters << FactoryBot.create(:host_parameter, :host => host, :name => 'remote_execution_ssh_ca_keys', :value => key)
102
+ assert_includes host.host_param('remote_execution_ssh_ca_keys'), key
103
+ assert_includes host.host_param('remote_execution_ssh_ca_keys'), ca_sshkey
104
+ end
105
+ end
106
+
64
107
  context 'host has multiple nics' do
65
108
  let(:host) { FactoryBot.build(:host, :with_execution) }
66
109
 
@@ -74,6 +74,10 @@ section.job-additional-info {
74
74
  margin-left: 10px;
75
75
  margin-right: 15px;
76
76
  }
77
+
78
+ .pf-v5-c-table__tbody > tr.row-hidden {
79
+ display: none;
80
+ }
77
81
  }
78
82
 
79
83
  .template-invocation {
@@ -24,7 +24,13 @@ import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableInde
24
24
  import { getControllerSearchProps } from 'foremanReact/constants';
25
25
  import { Icon } from 'patternfly-react';
26
26
  import PropTypes from 'prop-types';
27
- import React, { useEffect, useMemo, useState, useRef } from 'react';
27
+ import React, {
28
+ useEffect,
29
+ useMemo,
30
+ useState,
31
+ useRef,
32
+ useCallback,
33
+ } from 'react';
28
34
  import { FormattedMessage } from 'react-intl';
29
35
  import { useHistory } from 'react-router-dom';
30
36
  import { useForemanSettings } from 'foremanReact/Root/Context/ForemanContext';
@@ -61,9 +67,47 @@ const JobInvocationHostTable = ({
61
67
  const [allHostsIds, setAllHostsIds] = useState([]);
62
68
 
63
69
  // Expansive items
64
- const [expandedHost, setExpandedHost] = useState([]);
70
+ const [expandedHost, setExpandedHost] = useState(new Set());
65
71
  const prevStatusLabel = useRef(statusLabel);
66
72
 
73
+ const [hostInvocationStates, setHostInvocationStates] = useState({});
74
+
75
+ const getInvocationState = hostId =>
76
+ hostInvocationStates[hostId] || {
77
+ showOutputType: { stderr: true, stdout: true, debug: true },
78
+ showTemplatePreview: false,
79
+ showCommand: false,
80
+ };
81
+
82
+ const updateInvocationState = (hostId, stateKey, value) => {
83
+ setHostInvocationStates(prevStates => {
84
+ const currentHostState = getInvocationState(hostId);
85
+
86
+ const newValue =
87
+ typeof value === 'function' ? value(currentHostState[stateKey]) : value;
88
+
89
+ return {
90
+ ...prevStates,
91
+ [hostId]: {
92
+ ...currentHostState,
93
+ [stateKey]: newValue,
94
+ },
95
+ };
96
+ });
97
+ };
98
+
99
+ const isHostExpanded = hostId => expandedHost.has(hostId);
100
+ const setHostExpanded = (hostId, isExpanding = true) =>
101
+ setExpandedHost(prevExpandedSet => {
102
+ const newSet = new Set(prevExpandedSet);
103
+ if (isExpanding) {
104
+ newSet.add(hostId);
105
+ } else {
106
+ newSet.delete(hostId);
107
+ }
108
+ return newSet;
109
+ });
110
+
67
111
  // Page table params
68
112
  // Parse URL
69
113
  const {
@@ -93,19 +137,22 @@ const JobInvocationHostTable = ({
93
137
  });
94
138
 
95
139
  // Search filter
96
- const constructFilter = (filter = initialFilter, search = urlSearchQuery) => {
97
- const dropdownFilterClause =
98
- filter && filter !== 'all_statuses'
99
- ? `job_invocation.result = ${filter}`
100
- : null;
101
- const parts = [dropdownFilterClause, search];
102
- return parts
103
- .filter(x => x)
104
- .map(fragment => `(${fragment})`)
105
- .join(' AND ');
106
- };
140
+ const constructFilter = useCallback(
141
+ (filter = initialFilter, search = urlSearchQuery) => {
142
+ const dropdownFilterClause =
143
+ filter && filter !== 'all_statuses'
144
+ ? `job_invocation.result = ${filter}`
145
+ : null;
146
+ const parts = [dropdownFilterClause, search];
147
+ return parts
148
+ .filter(x => x)
149
+ .map(fragment => `(${fragment})`)
150
+ .join(' AND ');
151
+ },
152
+ [initialFilter, urlSearchQuery]
153
+ );
107
154
 
108
- const handleResponse = (data, key) => {
155
+ const handleResponse = useCallback((data, key) => {
109
156
  if (key === JOB_INVOCATION_HOSTS) {
110
157
  const ids = data.data.results.map(i => i.id);
111
158
 
@@ -114,76 +161,94 @@ const JobInvocationHostTable = ({
114
161
  }
115
162
 
116
163
  setStatus(STATUS_UPPERCASE.RESOLVED);
117
- };
164
+ }, []);
118
165
 
119
166
  // Call hosts data with params
120
- const makeApiCall = (requestParams, callParams = {}) => {
121
- dispatch(
122
- APIActions.get({
123
- key: callParams.key ?? ALL_JOB_HOSTS,
124
- url: callParams.url ?? `/api/job_invocations/${id}/hosts`,
125
- params: requestParams,
126
- handleSuccess: data => handleResponse(data, callParams.key),
127
- handleError: () => setStatus(STATUS_UPPERCASE.ERROR),
128
- errorToast: ({ response }) =>
129
- response?.data?.error?.full_messages?.[0] || response,
130
- })
131
- );
132
- };
167
+ const makeApiCall = useCallback(
168
+ (requestParams, callParams = {}) => {
169
+ dispatch(
170
+ APIActions.get({
171
+ key: callParams.key ?? ALL_JOB_HOSTS,
172
+ url: callParams.url ?? `/api/job_invocations/${id}/hosts`,
173
+ params: requestParams,
174
+ handleSuccess: data => handleResponse(data, callParams.key),
175
+ handleError: () => setStatus(STATUS_UPPERCASE.ERROR),
176
+ errorToast: ({ response }) =>
177
+ response?.data?.error?.full_messages?.[0] || response,
178
+ })
179
+ );
180
+ },
181
+ [dispatch, id, handleResponse]
182
+ );
133
183
 
134
- const filterApiCall = newAPIOptions => {
135
- const newParams = newAPIOptions?.params ?? newAPIOptions ?? {};
184
+ const filterApiCall = useCallback(
185
+ newAPIOptions => {
186
+ const newParams = newAPIOptions?.params ?? newAPIOptions ?? {};
136
187
 
137
- const filterSearch = constructFilter(
138
- initialFilter,
139
- newParams.search ?? urlSearchQuery
140
- );
188
+ const filterSearch = constructFilter(
189
+ initialFilter,
190
+ newParams.search ?? urlSearchQuery
191
+ );
141
192
 
142
- const finalParams = {
143
- ...defaultParams,
144
- ...newParams,
145
- };
193
+ const finalParams = {
194
+ ...defaultParams,
195
+ ...newParams,
196
+ };
146
197
 
147
- if (filterSearch === AWAITING_STATUS_FILTER) {
148
- finalParams.awaiting = 'true';
149
- } else if (filterSearch !== '') {
150
- finalParams.search = filterSearch;
151
- }
198
+ if (filterSearch === AWAITING_STATUS_FILTER) {
199
+ finalParams.awaiting = 'true';
200
+ } else if (filterSearch !== '') {
201
+ finalParams.search = filterSearch;
202
+ }
152
203
 
153
- makeApiCall(finalParams, { key: JOB_INVOCATION_HOSTS });
204
+ makeApiCall(finalParams, { key: JOB_INVOCATION_HOSTS });
154
205
 
155
- const urlSearchParams = new URLSearchParams(window.location.search);
206
+ const urlSearchParams = new URLSearchParams(window.location.search);
156
207
 
157
- ['page', 'per_page', 'order'].forEach(key => {
158
- if (finalParams[key]) urlSearchParams.set(key, finalParams[key]);
159
- });
208
+ ['page', 'per_page', 'order'].forEach(key => {
209
+ if (finalParams[key]) urlSearchParams.set(key, finalParams[key]);
210
+ });
160
211
 
161
- history.push({ search: urlSearchParams.toString() });
162
- };
212
+ history.push({ search: urlSearchParams.toString() });
213
+ },
214
+ [
215
+ initialFilter,
216
+ urlSearchQuery,
217
+ defaultParams,
218
+ makeApiCall,
219
+ history,
220
+ constructFilter,
221
+ ]
222
+ );
163
223
 
164
224
  // Filter change
165
- const handleFilterChange = newFilter => {
166
- onFilterUpdate(newFilter);
167
- };
225
+ const handleFilterChange = useCallback(
226
+ newFilter => {
227
+ onFilterUpdate(newFilter);
228
+ },
229
+ [onFilterUpdate]
230
+ );
168
231
 
169
232
  // Effects
170
233
  // run after mount
234
+ const initializedRef = useRef(false);
171
235
  useEffect(() => {
172
- // Job Invo template load
173
- makeApiCall(
174
- {},
175
- {
176
- url: `/job_invocations/${id}/hosts`,
177
- key: LIST_TEMPLATE_INVOCATIONS,
236
+ if (!initializedRef.current) {
237
+ // Job Invo template load
238
+ makeApiCall(
239
+ {},
240
+ {
241
+ url: `/job_invocations/${id}/hosts`,
242
+ key: LIST_TEMPLATE_INVOCATIONS,
243
+ }
244
+ );
245
+
246
+ if (initialFilter === '') {
247
+ onFilterUpdate('all_statuses');
178
248
  }
179
- );
180
-
181
- if (initialFilter === '') {
182
- onFilterUpdate('all_statuses');
249
+ initializedRef.current = true;
183
250
  }
184
-
185
- // eslint-disable-next-line react-hooks/exhaustive-deps
186
- }, []);
251
+ }, [makeApiCall, id, initialFilter, onFilterUpdate]);
187
252
 
188
253
  useEffect(() => {
189
254
  if (initialFilter !== '') filterApiCall();
@@ -192,8 +257,7 @@ const JobInvocationHostTable = ({
192
257
  prevStatusLabel.current = statusLabel;
193
258
  filterApiCall();
194
259
  }
195
- // eslint-disable-next-line react-hooks/exhaustive-deps
196
- }, [initialFilter, statusLabel, id]);
260
+ }, [initialFilter, statusLabel, id, filterApiCall]);
197
261
 
198
262
  const {
199
263
  updateSearchQuery: updateSearchQueryBulk,
@@ -307,29 +371,18 @@ const JobInvocationHostTable = ({
307
371
  </Tr>
308
372
  );
309
373
 
310
- const isHostExpanded = host => expandedHost.includes(host.id);
311
-
312
- const setHostExpanded = (host, isExpanding = true) =>
313
- setExpandedHost(prevExpanded => {
314
- const otherExpandedHosts = prevExpanded.filter(h => h !== host.id);
315
- return isExpanding
316
- ? [...otherExpandedHosts, host.id]
317
- : otherExpandedHosts;
318
- });
319
-
320
374
  const pageHostIds = results.map(h => h.id);
321
375
 
322
376
  const areAllPageRowsExpanded =
323
377
  pageHostIds.length > 0 &&
324
- pageHostIds.every(hostId => expandedHost.includes(hostId));
378
+ pageHostIds.every(hostId => expandedHost.has(hostId));
325
379
 
326
380
  const onExpandAll = () => {
327
381
  setExpandedHost(() => {
328
382
  if (areAllPageRowsExpanded) {
329
- return [];
383
+ return new Set();
330
384
  }
331
-
332
- return pageHostIds;
385
+ return new Set(pageHostIds);
333
386
  });
334
387
  };
335
388
 
@@ -393,54 +446,88 @@ const JobInvocationHostTable = ({
393
446
  isDeleteable={false}
394
447
  childrenOutsideTbody
395
448
  >
396
- {results.map((result, rowIndex) => (
397
- <Tbody key={result.id} isExpanded={isHostExpanded(result)}>
398
- <Tr ouiaId={`table-row-${result.id}`}>
399
- <Td
400
- expand={{
401
- rowIndex,
402
- isExpanded: isHostExpanded(result),
403
- onToggle: () =>
404
- setHostExpanded(result, !isHostExpanded(result)),
405
- expandId: 'host-expandable',
406
- }}
407
- />
408
- <RowSelectTd rowData={result} {...{ selectOne, isSelected }} />
409
- {columnNamesKeys.map(k => (
410
- <Td key={k}>{columns[k].wrapper(result)}</Td>
411
- ))}
412
- <Td isActionCell>
413
- <RowActions hostID={result.id} jobID={id} />
414
- </Td>
415
- </Tr>
416
- <Tr
417
- isExpanded={isHostExpanded(result)}
418
- ouiaId="table-row-expanded-sections"
419
- >
420
- <Td
421
- dataLabel={`${result.id}-expandable-content`}
422
- colSpan={columnNamesKeys.length + 3}
449
+ {results.map((result, rowIndex) => {
450
+ const currentInvocationState = getInvocationState(result.id);
451
+ return (
452
+ <Tbody key={result.id}>
453
+ <Tr ouiaId={`table-row-${result.id}`}>
454
+ <Td
455
+ expand={{
456
+ rowIndex,
457
+ isExpanded: isHostExpanded(result.id),
458
+ onToggle: () =>
459
+ setHostExpanded(result.id, !isHostExpanded(result.id)),
460
+ expandId: 'host-expandable',
461
+ }}
462
+ />
463
+ <RowSelectTd
464
+ rowData={result}
465
+ selectOne={selectOne}
466
+ isSelected={isSelected}
467
+ />
468
+ {columnNamesKeys.map(k => (
469
+ <Td key={k}>{columns[k].wrapper(result)}</Td>
470
+ ))}
471
+ <Td isActionCell>
472
+ <RowActions hostID={result.id} jobID={id} />
473
+ </Td>
474
+ </Tr>
475
+ <Tr
476
+ isExpanded={isHostExpanded(result.id)}
477
+ ouiaId="table-row-expanded-sections"
478
+ className={!isHostExpanded(result.id) ? 'row-hidden' : ''}
423
479
  >
424
- <ExpandableRowContent>
425
- {result.job_status === 'cancelled' ||
426
- result.job_status === 'N/A' ? (
427
- <div>
428
- {__('A task for this host has not been started')}
429
- </div>
430
- ) : (
431
- <TemplateInvocation
432
- key={`${result.id}-${result.job_status}`}
433
- hostID={result.id}
434
- jobID={id}
435
- isInTableView
436
- isExpanded={isHostExpanded(result)}
437
- />
438
- )}
439
- </ExpandableRowContent>
440
- </Td>
441
- </Tr>
442
- </Tbody>
443
- ))}
480
+ <Td
481
+ dataLabel={`${result.id}-expandable-content`}
482
+ colSpan={columnNamesKeys.length + 3}
483
+ >
484
+ <ExpandableRowContent>
485
+ {result.job_status === 'cancelled' ||
486
+ result.job_status === 'N/A' ? (
487
+ <div>
488
+ {__('A task for this host has not been started')}
489
+ </div>
490
+ ) : (
491
+ <TemplateInvocation
492
+ key={result.id}
493
+ hostID={result.id}
494
+ jobID={id}
495
+ isInTableView
496
+ isExpanded={isHostExpanded(result.id)}
497
+ showOutputType={currentInvocationState.showOutputType}
498
+ showTemplatePreview={
499
+ currentInvocationState.showTemplatePreview
500
+ }
501
+ showCommand={currentInvocationState.showCommand}
502
+ setShowOutputType={value =>
503
+ updateInvocationState(
504
+ result.id,
505
+ 'showOutputType',
506
+ value
507
+ )
508
+ }
509
+ setShowTemplatePreview={value =>
510
+ updateInvocationState(
511
+ result.id,
512
+ 'showTemplatePreview',
513
+ value
514
+ )
515
+ }
516
+ setShowCommand={value =>
517
+ updateInvocationState(
518
+ result.id,
519
+ 'showCommand',
520
+ value
521
+ )
522
+ }
523
+ />
524
+ )}
525
+ </ExpandableRowContent>
526
+ </Td>
527
+ </Tr>
528
+ </Tbody>
529
+ );
530
+ })}
444
531
  </Table>
445
532
  </TableIndexPage>
446
533
  </>