foreman_remote_execution 13.0.0 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.eslintrc +4 -1
  3. data/.github/workflows/release.yml +4 -2
  4. data/app/lib/actions/remote_execution/run_host_job.rb +0 -14
  5. data/app/models/concerns/foreman_remote_execution/host_extensions.rb +8 -0
  6. data/app/models/job_invocation_composer.rb +4 -3
  7. data/app/views/api/v2/job_invocations/base.json.rabl +5 -3
  8. data/app/views/templates/script/convert2rhel_analyze.erb +1 -12
  9. data/app/views/templates/script/package_action.erb +11 -1
  10. data/app/views/templates/script/puppet_run_once.erb +3 -3
  11. data/lib/foreman_remote_execution/version.rb +1 -1
  12. data/package.json +6 -6
  13. data/test/unit/job_invocation_composer_test.rb +31 -3
  14. data/webpack/JobInvocationDetail/JobInvocationConstants.js +10 -0
  15. data/webpack/JobInvocationDetail/JobInvocationDetail.scss +38 -0
  16. data/webpack/JobInvocationDetail/JobInvocationOverview.js +13 -25
  17. data/webpack/JobInvocationDetail/JobInvocationSystemStatusChart.js +153 -0
  18. data/webpack/JobInvocationDetail/index.js +48 -10
  19. data/webpack/JobWizard/Footer.js +5 -1
  20. data/webpack/JobWizard/JobWizardPageRerun.js +3 -0
  21. data/webpack/JobWizard/StartsBeforeErrorAlert.js +1 -0
  22. data/webpack/JobWizard/index.js +1 -0
  23. data/webpack/JobWizard/steps/AdvancedFields/DescriptionField.js +8 -1
  24. data/webpack/JobWizard/steps/AdvancedFields/Fields.js +7 -0
  25. data/webpack/JobWizard/steps/CategoryAndTemplate/CategoryAndTemplate.js +16 -3
  26. data/webpack/JobWizard/steps/HostsAndInputs/HostPreviewModal.js +5 -1
  27. data/webpack/JobWizard/steps/HostsAndInputs/SelectedChips.js +8 -1
  28. data/webpack/JobWizard/steps/HostsAndInputs/index.js +7 -2
  29. data/webpack/JobWizard/steps/ReviewDetails/index.js +4 -0
  30. data/webpack/JobWizard/steps/Schedule/PurposeField.js +1 -0
  31. data/webpack/JobWizard/steps/Schedule/QueryType.js +2 -0
  32. data/webpack/JobWizard/steps/Schedule/RepeatCron.js +1 -0
  33. data/webpack/JobWizard/steps/Schedule/RepeatHour.js +2 -0
  34. data/webpack/JobWizard/steps/Schedule/RepeatMonth.js +1 -0
  35. data/webpack/JobWizard/steps/Schedule/RepeatWeek.js +1 -0
  36. data/webpack/JobWizard/steps/Schedule/ScheduleFuture.js +2 -0
  37. data/webpack/JobWizard/steps/Schedule/ScheduleRecurring.js +6 -0
  38. data/webpack/JobWizard/steps/Schedule/ScheduleType.js +3 -0
  39. data/webpack/JobWizard/steps/form/FormHelpers.js +1 -0
  40. data/webpack/JobWizard/steps/form/GroupedSelectField.js +1 -0
  41. data/webpack/JobWizard/steps/form/NumberInput.js +1 -0
  42. data/webpack/JobWizard/steps/form/ResourceSelect.js +1 -0
  43. data/webpack/JobWizard/steps/form/SearchSelect.js +1 -0
  44. data/webpack/JobWizard/steps/form/SelectField.js +1 -0
  45. data/webpack/JobWizard/steps/form/WizardTitle.js +6 -1
  46. data/webpack/__mocks__/foremanReact/routes/Hosts/constants.js +1 -0
  47. data/webpack/react_app/components/FeaturesDropdown/index.js +1 -0
  48. data/webpack/react_app/components/RecentJobsCard/RecentJobsCard.js +3 -0
  49. data/webpack/react_app/components/RecentJobsCard/RecentJobsTable.js +2 -1
  50. data/webpack/react_app/components/RegistrationExtension/RexInterface.js +1 -0
  51. data/webpack/react_app/components/RegistrationExtension/RexPull.js +1 -0
  52. data/webpack/react_app/components/RegistrationExtension/__tests__/__snapshots__/RexInterface.test.js.snap +1 -0
  53. metadata +9 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a5e47997164244116ccb9c37d8eb7f7ec32b36d953ddcba6763ba86afc84a0f
4
- data.tar.gz: ec69e4b0ef576ba474949648e164ba6d6bcd252bc4f9e501c7adaa8dc69b4da9
3
+ metadata.gz: 0c70b663aba31ca9cbe32da992a3bfa346232b535c720d578ade8a78772e0a38
4
+ data.tar.gz: 29b2f8d63af5a7a360b18db895805c3adc781042e06f928ac875b7a048f15090
5
5
  SHA512:
6
- metadata.gz: e3cf4ecf6c5834264479776dad8342b039964664ab2ff9b388da168c9ad5337d7b2f013d435bf64e6aedc15929a0147e882d068883b1d1dabaeac82a09193f72
7
- data.tar.gz: c41da5c789687b7374a4412c12d3a20befeda3a46d1ab087d12da91a053c82a1fd6d63164d4a11043cb4931c899012e2da9b57c1f250893ed1ec79a2db8e03a1
6
+ metadata.gz: 30c819eda158efaabec5b9aa47f01fd61bf9b92ff595f20e0646f5e91eae4ad1b4c9a5e05ffb7d0ecc442c19c211ed6b46e8eccaba831f36eef36886c11cd155
7
+ data.tar.gz: 2b60fb76b97d97b7491eb87b2c1c454d024b5be5ffa48de1acad41575eb9fd761a2d70aecda133b5d7f4604150c88524ca7606185b2fd2d0a86760eb4c9a9433
data/.eslintrc CHANGED
@@ -6,5 +6,8 @@
6
6
  "extends": [
7
7
  "plugin:@theforeman/foreman/core",
8
8
  "plugin:@theforeman/foreman/plugins"
9
- ]
9
+ ],
10
+ "rules":{
11
+ "@theforeman/rules/require-ouiaid": ["error"]
12
+ }
10
13
  }
@@ -1,8 +1,10 @@
1
1
  name: Release
2
2
 
3
3
  on:
4
- create:
5
- ref_type: tag
4
+ push:
5
+ # Pattern matched against refs/tags
6
+ tags:
7
+ - '**'
6
8
 
7
9
  jobs:
8
10
  release:
@@ -204,20 +204,6 @@ module Actions
204
204
  host.refresh_global_status!
205
205
  end
206
206
 
207
- def delegated_output
208
- if input[:delegated_action_id]
209
- super
210
- elsif phase?(Present)
211
- # for compatibility with old actions
212
- old_action = all_planned_actions.first
213
- if old_action
214
- old_action.output.fetch('proxy_output', {})
215
- else
216
- {}
217
- end
218
- end
219
- end
220
-
221
207
  def verify_permissions(host, template_invocation)
222
208
  raise _('User can not execute job on host %s') % host.name unless User.current.can?(:view_hosts, host)
223
209
  raise _('User can not execute this job template') unless User.current.can?(:view_job_templates, template_invocation.template)
@@ -152,3 +152,11 @@ module ForemanRemoteExecution
152
152
  end
153
153
  end
154
154
  end
155
+
156
+ module Host
157
+ class Managed
158
+ class Jail < Safemode::Jail
159
+ allow :execution_interface
160
+ end
161
+ end
162
+ end
@@ -569,9 +569,10 @@ class JobInvocationComposer
569
569
  # builds input values for a given templates id based on params
570
570
  # omits inputs that belongs to unavailable templates
571
571
  def build_input_values_for(template_invocation, job_template_base)
572
- template_invocation.input_values = job_template_base.fetch('input_values', {}).map do |attributes|
573
- input = template_invocation.template.template_inputs_with_foreign.find { |i| i.id.to_s == attributes[:template_input_id].to_s }
574
- input ? input.template_invocation_input_values.build(attributes) : nil
572
+ template_invocation.input_values = template_invocation.template.template_inputs_with_foreign.map do |input|
573
+ attributes = job_template_base.fetch('input_values', {}).find { |i| i[:template_input_id].to_s == input.id.to_s }
574
+ attributes = { "template_input_id" => input.id, "value" => input.default } if attributes.nil? && input.default
575
+ attributes ? input.template_invocation_input_values.build(attributes) : nil
575
576
  end.compact
576
577
  template_invocation.provider_input_values.build job_template_base.fetch('provider_input_values', [])
577
578
  end
@@ -8,11 +8,13 @@ node do |invocation|
8
8
  :template_id => pattern_template&.template_id,
9
9
  :template_name => pattern_template&.template_name,
10
10
  :effective_user => pattern_template&.effective_user,
11
- :succeeded => invocation_count(invocation, :output_key => :success_count),
12
- :failed => invocation_count(invocation, :output_key => :failed_count),
13
- :pending => invocation_count(invocation, :output_key => :pending_count),
11
+ :succeeded => invocation.progress_report[:success],
12
+ :failed => invocation.progress_report[:error],
13
+ :pending => invocation.progress_report[:pending],
14
+ :cancelled => invocation.progress_report[:cancelled],
14
15
  :total => invocation_count(invocation, :output_key => :total_count),
15
16
  :missing => invocation.missing_hosts_count,
17
+ :total_hosts => invocation.total_hosts_count,
16
18
  }
17
19
  end
18
20
 
@@ -1,15 +1,6 @@
1
1
  <%#
2
2
  name: Convert2RHEL analyze
3
3
  snippet: false
4
- template_inputs:
5
- - name: Data telemetry
6
- required: false
7
- input_type: user
8
- options: "yes\r\nno"
9
- advanced: false
10
- value_type: plain
11
- default: 'yes'
12
- hidden_value: false
13
4
  model: JobTemplate
14
5
  job_category: Convert 2 RHEL
15
6
  provider_type: script
@@ -25,9 +16,7 @@ if ! rpm -q convert2rhel &> /dev/null; then
25
16
  yum install -y convert2rhel
26
17
  fi
27
18
 
28
- <% if input('Data telemetry') != "yes" -%>
29
- export CONVERT2RHEL_DISABLE_TELEMETRY=1
30
- <% end -%>
19
+ export CONVERT2RHEL_THROUGH_FOREMAN=1
31
20
 
32
21
  /usr/bin/convert2rhel analyze -y
33
22
 
@@ -115,9 +115,19 @@ handle_zypp_res_codes () {
115
115
  end
116
116
  end
117
117
  -%>
118
+ OUTFILE=$(mktemp)
119
+ trap "rm -f $OUTFILE" EXIT
120
+ set -o pipefail
118
121
  export DEBIAN_FRONTEND=noninteractive
119
122
  apt-get -y update
120
- apt-get -o APT::Get::Upgrade-Allow-New="true" -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" -y <%= input("options") %> <%= action %> <%= input("package") %>
123
+ LANG=C apt-get -o APT::Get::Upgrade-Allow-New="true" -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" -y <%= input("options") %> <%= action %> <%= input("package") %> 2>&1 | tee $OUTFILE
124
+ RETVAL=$?
125
+ if grep -q "Unable to locate" $OUTFILE; then
126
+ true
127
+ else
128
+ setReturnValue() { RETVAL=$1; return $RETVAL; }
129
+ setReturnValue $RETVAL
130
+ fi
121
131
  <% elsif package_manager == 'zypper' -%>
122
132
  <%-
123
133
  if action == "group install"
@@ -12,7 +12,7 @@ template_inputs:
12
12
  required: false
13
13
  feature: puppet_run_host
14
14
  %>
15
- <% if @host.operatingsystem.family == 'Debian' -%>
16
- export PATH=/opt/puppetlabs/bin:$PATH
17
- <% end -%>
15
+ if [ -f /etc/profile.d/puppet-agent.sh ]; then
16
+ . /etc/profile.d/puppet-agent.sh
17
+ fi
18
18
  puppet agent --onetime --no-usecacheonfailure --no-daemonize <%= input("puppet_options") %>
@@ -1,3 +1,3 @@
1
1
  module ForemanRemoteExecution
2
- VERSION = '13.0.0'.freeze
2
+ VERSION = '13.1.0'.freeze
3
3
  end
data/package.json CHANGED
@@ -19,11 +19,11 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "@babel/core": "^7.7.0",
22
- "@theforeman/builder": "^12.0.1",
23
- "@theforeman/eslint-plugin-foreman": "^12.0.1",
24
- "@theforeman/eslint-plugin-rules": "^12.0.2",
25
- "@theforeman/test": "^12.0.1",
26
- "@theforeman/vendor-dev": "^12.0.1",
22
+ "@theforeman/builder": ">= 12.0.1",
23
+ "@theforeman/eslint-plugin-foreman": ">= 12.0.1",
24
+ "@theforeman/eslint-plugin-rules": ">= 12.0.2",
25
+ "@theforeman/test": ">= 12.0.1",
26
+ "@theforeman/vendor-dev": ">= 12.0.1",
27
27
  "babel-eslint": "^10.0.0",
28
28
  "eslint": "^6.8.0",
29
29
  "prettier": "^1.19.1",
@@ -32,6 +32,6 @@
32
32
  "graphql": "^15.5.0"
33
33
  },
34
34
  "peerDependencies": {
35
- "@theforeman/vendor": "^12.0.1"
35
+ "@theforeman/vendor": ">= 12.0.1"
36
36
  }
37
37
  }
@@ -35,6 +35,7 @@ class JobInvocationComposerTest < ActiveSupport::TestCase
35
35
  let(:trying_job_template_1) { FactoryBot.create(:job_template, :job_category => 'trying_job_template_1', :name => 'trying1', :provider_type => 'SSH') }
36
36
  let(:trying_job_template_2) { FactoryBot.create(:job_template, :job_category => 'trying_job_template_2', :name => 'trying2', :provider_type => 'Mcollective') }
37
37
  let(:trying_job_template_3) { FactoryBot.create(:job_template, :job_category => 'trying_job_template_1', :name => 'trying3', :provider_type => 'SSH') }
38
+ let(:trying_job_template_5) { FactoryBot.create(:job_template, :job_category => 'trying_job_template_5', :name => 'trying5', :provider_type => 'SSH') }
38
39
  let(:unauthorized_job_template_1) { FactoryBot.create(:job_template, :job_category => 'trying_job_template_1', :name => 'unauth1', :provider_type => 'SSH') }
39
40
  let(:unauthorized_job_template_2) { FactoryBot.create(:job_template, :job_category => 'unauthorized_job_template_2', :name => 'unauth2', :provider_type => 'Ansible') }
40
41
 
@@ -44,6 +45,7 @@ class JobInvocationComposerTest < ActiveSupport::TestCase
44
45
  let(:input2) { FactoryBot.create(:template_input, :template => trying_job_template_3, :input_type => 'user') }
45
46
  let(:input3) { FactoryBot.create(:template_input, :template => trying_job_template_1, :input_type => 'user', :required => true) }
46
47
  let(:input4) { FactoryBot.create(:template_input, :template => provider_inputs_job_template, :input_type => 'user') }
48
+ let(:input5) { FactoryBot.create(:template_input, :template => trying_job_template_5, :input_type => 'user', :default => 'value') }
47
49
  let(:unauthorized_input1) { FactoryBot.create(:template_input, :template => unauthorized_job_template_1, :input_type => 'user') }
48
50
 
49
51
  let(:ansible_params) { { } }
@@ -654,14 +656,40 @@ class JobInvocationComposerTest < ActiveSupport::TestCase
654
656
 
655
657
  context 'with with inputs' do
656
658
  let(:params) do
657
- { :job_category => trying_job_template_1.job_category,
658
- :job_template_id => trying_job_template_1.id,
659
+ { :job_category => trying_job_template_5.job_category,
660
+ :job_template_id => trying_job_template_5.id,
659
661
  :targeting_type => 'static_query',
660
662
  :search_query => 'some hosts',
661
- :inputs => {input1.name => 'some_value'}}
663
+ :inputs => {input5.name => 'some_value'}}
662
664
  end
663
665
 
664
666
  it 'finds the inputs by name' do
667
+ assert composer.save!
668
+ values = composer.pattern_template_invocations.first.input_values
669
+ assert_equal 1, values.count
670
+ assert_equal 'some_value', values.first.value
671
+ end
672
+
673
+ it 'can be forced to be empty' do
674
+ params[:inputs] = {input5.name => ''}
675
+ assert composer.save!
676
+ values = composer.pattern_template_invocations.first.input_values
677
+ assert_equal 1, values.count
678
+ assert_equal '', values.first.value
679
+ end
680
+ end
681
+
682
+ context 'with inputs and default values' do
683
+ let(:params) do
684
+ { :job_category => trying_job_template_5.job_category,
685
+ :job_template_id => trying_job_template_5.id,
686
+ :targeting_type => 'static_query',
687
+ :search_query => 'some hosts',
688
+ :inputs => {}}
689
+ end
690
+
691
+ it 'uses the default input values' do
692
+ input5 # Force the factory to be materialized
665
693
  assert composer.save!
666
694
  assert_equal 1, composer.pattern_template_invocations.first.input_values.collect.count
667
695
  end
@@ -5,3 +5,13 @@ export const STATUS = {
5
5
  SUCCEEDED: 'succeeded',
6
6
  FAILED: 'failed',
7
7
  };
8
+
9
+ export const DATE_OPTIONS = {
10
+ day: 'numeric',
11
+ month: 'short',
12
+ year: 'numeric',
13
+ hour: '2-digit',
14
+ minute: '2-digit',
15
+ hour12: false,
16
+ timeZoneName: 'short',
17
+ };
@@ -0,0 +1,38 @@
1
+ .job-invocation-detail-page-section {
2
+ $chart_size: 105px;
3
+
4
+ .chart-donut {
5
+ height: $chart_size;
6
+ width: $chart_size;
7
+ margin-bottom: 10px;
8
+ }
9
+
10
+ .chart-legend {
11
+ height: $chart_size;
12
+ min-width: 270px;
13
+
14
+ .legend-title {
15
+ font-weight: bold;
16
+ font-size: var(--pf-global--FontSize--sm);
17
+ margin-left: 8px;
18
+ margin-bottom: 0;
19
+ }
20
+
21
+ .pf-c-description-list {
22
+ margin-left: 8px;
23
+ margin-top: 8px;
24
+
25
+ .pf-c-description-list__term .pf-c-description-list__text {
26
+ font-weight: normal;
27
+ }
28
+ }
29
+ }
30
+
31
+ .pf-c-divider {
32
+ max-height: $chart_size;
33
+ }
34
+
35
+ .job-overview {
36
+ height: $chart_size;
37
+ }
38
+ }
@@ -7,12 +7,15 @@ import {
7
7
  DescriptionListGroup,
8
8
  DescriptionListDescription,
9
9
  } from '@patternfly/react-core';
10
+ import { translate as __ } from 'foremanReact/common/I18n';
10
11
  import DefaultLoaderEmptyState from 'foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState';
11
- import { translate as __, documentLocale } from 'foremanReact/common/I18n';
12
12
 
13
- const JobInvocationOverview = ({ data }) => {
13
+ const JobInvocationOverview = ({
14
+ data,
15
+ isAlreadyStarted,
16
+ formattedStartDate,
17
+ }) => {
14
18
  const {
15
- start_at: startAt,
16
19
  ssh_user: sshUser,
17
20
  template_id: templateId,
18
21
  template_name: templateName,
@@ -22,27 +25,6 @@ const JobInvocationOverview = ({ data }) => {
22
25
  const canEditJobTemplates = permissions
23
26
  ? permissions.edit_job_templates
24
27
  : false;
25
- const dateOptions = {
26
- day: 'numeric',
27
- month: 'short',
28
- year: 'numeric',
29
- hour: '2-digit',
30
- minute: '2-digit',
31
- hour12: false,
32
- timeZoneName: 'short',
33
- };
34
- let formattedStartDate = __('Not yet');
35
-
36
- if (startAt) {
37
- // Ensures date string compatibility across browsers
38
- const convertedDate = new Date(startAt.replace(/[-.]/g, '/'));
39
- if (convertedDate.getTime() <= new Date().getTime()) {
40
- formattedStartDate = convertedDate.toLocaleString(
41
- documentLocale(),
42
- dateOptions
43
- );
44
- }
45
- }
46
28
 
47
29
  return (
48
30
  <DescriptionList
@@ -63,7 +45,7 @@ const JobInvocationOverview = ({ data }) => {
63
45
  <DescriptionListGroup>
64
46
  <DescriptionListTerm>{__('Started at:')}</DescriptionListTerm>
65
47
  <DescriptionListDescription>
66
- {formattedStartDate}
48
+ {isAlreadyStarted ? formattedStartDate : __('Not yet')}
67
49
  </DescriptionListDescription>
68
50
  </DescriptionListGroup>
69
51
  <DescriptionListGroup>
@@ -99,6 +81,12 @@ const JobInvocationOverview = ({ data }) => {
99
81
 
100
82
  JobInvocationOverview.propTypes = {
101
83
  data: PropTypes.object.isRequired,
84
+ isAlreadyStarted: PropTypes.bool.isRequired,
85
+ formattedStartDate: PropTypes.string,
86
+ };
87
+
88
+ JobInvocationOverview.defaultProps = {
89
+ formattedStartDate: undefined,
102
90
  };
103
91
 
104
92
  export default JobInvocationOverview;
@@ -0,0 +1,153 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import PropTypes from 'prop-types';
3
+ import { translate as __, sprintf } from 'foremanReact/common/I18n';
4
+ import {
5
+ ChartDonut,
6
+ ChartLabel,
7
+ ChartLegend,
8
+ ChartTooltip,
9
+ } from '@patternfly/react-charts';
10
+ import {
11
+ DescriptionList,
12
+ DescriptionListTerm,
13
+ DescriptionListGroup,
14
+ DescriptionListDescription,
15
+ FlexItem,
16
+ Text,
17
+ } from '@patternfly/react-core';
18
+ import {
19
+ global_palette_green_500 as successedColor,
20
+ global_palette_red_100 as failedColor,
21
+ global_palette_blue_300 as inProgressColor,
22
+ global_palette_black_600 as canceledColor,
23
+ global_palette_black_500 as emptyChartDonut,
24
+ } from '@patternfly/react-tokens';
25
+ import DefaultLoaderEmptyState from 'foremanReact/components/HostDetails/DetailsCard/DefaultLoaderEmptyState';
26
+ import './JobInvocationDetail.scss';
27
+
28
+ const JobInvocationSystemStatusChart = ({
29
+ data,
30
+ isAlreadyStarted,
31
+ formattedStartDate,
32
+ }) => {
33
+ const {
34
+ succeeded,
35
+ failed,
36
+ pending,
37
+ cancelled,
38
+ total,
39
+ total_hosts: totalHosts, // includes scheduled
40
+ } = data;
41
+ const chartData = [
42
+ { title: __('Succeeded:'), count: succeeded, color: successedColor.value },
43
+ { title: __('Failed:'), count: failed, color: failedColor.value },
44
+ { title: __('In Progress:'), count: pending, color: inProgressColor.value },
45
+ { title: __('Canceled:'), count: cancelled, color: canceledColor.value },
46
+ ];
47
+ const chartDonutTitle = () => {
48
+ if (total > 0) return `${succeeded.toString()}/${total}`;
49
+ if (totalHosts > 0) return `0/${totalHosts}`;
50
+ return '0';
51
+ };
52
+ const chartSize = 105;
53
+ const [legendWidth, setLegendWidth] = useState(270);
54
+
55
+ // Calculates chart legend width based on its content
56
+ useEffect(() => {
57
+ const legendContainer = document.querySelector('.chart-legend');
58
+ if (legendContainer) {
59
+ const rectElement = legendContainer.querySelector('rect');
60
+ if (rectElement) {
61
+ const rectWidth = parseFloat(rectElement.getAttribute('width'));
62
+ setLegendWidth(rectWidth);
63
+ }
64
+ }
65
+ }, [isAlreadyStarted, data]);
66
+
67
+ return (
68
+ <>
69
+ <FlexItem className="chart-donut">
70
+ <ChartDonut
71
+ allowTooltip
72
+ constrainToVisibleArea
73
+ data={
74
+ total > 0
75
+ ? chartData.map(d => ({
76
+ label: sprintf(__(`${d.title} ${d.count} hosts`)),
77
+ y: d.count,
78
+ }))
79
+ : [{ label: sprintf(__(`Scheduled: ${totalHosts} hosts`)), y: 1 }]
80
+ }
81
+ colorScale={
82
+ total > 0 ? chartData.map(d => d.color) : [emptyChartDonut.value]
83
+ }
84
+ labelComponent={
85
+ <ChartTooltip pointerLength={0} constrainToVisibleArea />
86
+ }
87
+ title={chartDonutTitle}
88
+ titleComponent={
89
+ // inline style overrides PatternFly default styling
90
+ <ChartLabel style={{ fontSize: '20px' }} />
91
+ }
92
+ subTitle={__('Systems')}
93
+ subTitleComponent={
94
+ // inline style overrides PatternFly default styling
95
+ <ChartLabel
96
+ style={{ fontSize: '12px', fill: canceledColor.value }}
97
+ />
98
+ }
99
+ padding={{
100
+ bottom: 0,
101
+ left: 0,
102
+ right: 0,
103
+ top: 0,
104
+ }}
105
+ width={chartSize}
106
+ height={chartSize}
107
+ />
108
+ </FlexItem>
109
+ <FlexItem className="chart-legend">
110
+ <Text ouiaId="legend-title" className="legend-title">
111
+ {__('System status')}
112
+ </Text>
113
+ {isAlreadyStarted ? (
114
+ <ChartLegend
115
+ orientation="vertical"
116
+ itemsPerRow={2}
117
+ gutter={25}
118
+ rowGutter={7}
119
+ padding={{ left: 15 }}
120
+ data={chartData.map(d => ({
121
+ name: `${d.title} ${d.count}`,
122
+ symbol: { type: 'circle' },
123
+ }))}
124
+ colorScale={chartData.map(d => d.color)}
125
+ width={legendWidth}
126
+ height={chartSize}
127
+ />
128
+ ) : (
129
+ <DescriptionList>
130
+ <DescriptionListGroup>
131
+ <DescriptionListTerm>{__('Scheduled at:')}</DescriptionListTerm>
132
+ <DescriptionListDescription>
133
+ {formattedStartDate || <DefaultLoaderEmptyState />}
134
+ </DescriptionListDescription>
135
+ </DescriptionListGroup>
136
+ </DescriptionList>
137
+ )}
138
+ </FlexItem>
139
+ </>
140
+ );
141
+ };
142
+
143
+ JobInvocationSystemStatusChart.propTypes = {
144
+ data: PropTypes.object.isRequired,
145
+ isAlreadyStarted: PropTypes.bool.isRequired,
146
+ formattedStartDate: PropTypes.string,
147
+ };
148
+
149
+ JobInvocationSystemStatusChart.defaultProps = {
150
+ formattedStartDate: undefined,
151
+ };
152
+
153
+ export default JobInvocationSystemStatusChart;
@@ -1,14 +1,20 @@
1
1
  import React, { useEffect } from 'react';
2
2
  import { useSelector, useDispatch } from 'react-redux';
3
3
  import PropTypes from 'prop-types';
4
- import { Divider, PageSection, Flex, FlexItem } from '@patternfly/react-core';
5
- import { translate as __ } from 'foremanReact/common/I18n';
4
+ import { Divider, PageSection, Flex } from '@patternfly/react-core';
5
+ import { translate as __, documentLocale } from 'foremanReact/common/I18n';
6
6
  import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
7
7
  import { stopInterval } from 'foremanReact/redux/middlewares/IntervalMiddleware';
8
8
  import { getData } from './JobInvocationActions';
9
9
  import { selectItems } from './JobInvocationSelectors';
10
10
  import JobInvocationOverview from './JobInvocationOverview';
11
- import { JOB_INVOCATION_KEY, STATUS } from './JobInvocationConstants';
11
+ import JobInvocationSystemStatusChart from './JobInvocationSystemStatusChart';
12
+ import {
13
+ JOB_INVOCATION_KEY,
14
+ STATUS,
15
+ DATE_OPTIONS,
16
+ } from './JobInvocationConstants';
17
+ import './JobInvocationDetail.scss';
12
18
 
13
19
  const JobInvocationDetailPage = ({
14
20
  match: {
@@ -17,11 +23,28 @@ const JobInvocationDetailPage = ({
17
23
  }) => {
18
24
  const dispatch = useDispatch();
19
25
  const items = useSelector(selectItems);
20
- const { description, status_label: statusLabel, task } = items;
26
+ const {
27
+ description,
28
+ status_label: statusLabel,
29
+ task,
30
+ start_at: startAt,
31
+ } = items;
21
32
  const finished =
22
33
  statusLabel === STATUS.FAILED || statusLabel === STATUS.SUCCEEDED;
23
34
  const autoRefresh = task?.state === STATUS.PENDING || false;
24
35
 
36
+ let isAlreadyStarted = false;
37
+ let formattedStartDate;
38
+ if (startAt) {
39
+ // Ensures date string compatibility across browsers
40
+ const convertedDate = new Date(startAt.replace(/[-.]/g, '/'));
41
+ isAlreadyStarted = convertedDate.getTime() <= new Date().getTime();
42
+ formattedStartDate = convertedDate.toLocaleString(
43
+ documentLocale(),
44
+ DATE_OPTIONS
45
+ );
46
+ }
47
+
25
48
  useEffect(() => {
26
49
  dispatch(getData(`/api/job_invocations/${id}`));
27
50
  if (finished && !autoRefresh) {
@@ -48,17 +71,32 @@ const JobInvocationDetailPage = ({
48
71
  searchable={false}
49
72
  >
50
73
  <React.Fragment>
51
- <PageSection isFilled variant="light">
52
- <Flex>
53
- <FlexItem> </FlexItem>
74
+ <PageSection
75
+ className="job-invocation-detail-page-section"
76
+ isFilled
77
+ variant="light"
78
+ >
79
+ <Flex alignItems={{ default: 'alignItemsFlexStart' }}>
80
+ <JobInvocationSystemStatusChart
81
+ data={items}
82
+ isAlreadyStarted={isAlreadyStarted}
83
+ formattedStartDate={formattedStartDate}
84
+ />
54
85
  <Divider
55
86
  orientation={{
56
87
  default: 'vertical',
57
88
  }}
58
89
  />
59
- <FlexItem>
60
- <JobInvocationOverview data={items} />
61
- </FlexItem>
90
+ <Flex
91
+ className="job-overview"
92
+ alignItems={{ default: 'alignItemsCenter' }}
93
+ >
94
+ <JobInvocationOverview
95
+ data={items}
96
+ isAlreadyStarted={isAlreadyStarted}
97
+ formattedStartDate={formattedStartDate}
98
+ />
99
+ </Flex>
62
100
  </Flex>
63
101
  </PageSection>
64
102
  </React.Fragment>
@@ -26,6 +26,7 @@ export const Footer = ({ canSubmit, onSave }) => {
26
26
  return (
27
27
  <>
28
28
  <Button
29
+ ouiaId="next-footer"
29
30
  variant="primary"
30
31
  type="submit"
31
32
  onClick={onNext}
@@ -36,6 +37,7 @@ export const Footer = ({ canSubmit, onSave }) => {
36
37
  : __('Next')}
37
38
  </Button>
38
39
  <Button
40
+ ouiaId="back-footer"
39
41
  variant="secondary"
40
42
  onClick={onBack}
41
43
  isDisabled={
@@ -54,6 +56,7 @@ export const Footer = ({ canSubmit, onSave }) => {
54
56
  }
55
57
  >
56
58
  <Button
59
+ ouiaId="run-on-selected-hosts-footer"
57
60
  variant="tertiary"
58
61
  onClick={onSave}
59
62
  isAriaDisabled={!canSubmit}
@@ -74,6 +77,7 @@ export const Footer = ({ canSubmit, onSave }) => {
74
77
  }
75
78
  >
76
79
  <Button
80
+ ouiaId="skip-to-review-footer"
77
81
  variant="tertiary"
78
82
  onClick={() => goToStepByName(WIZARD_TITLES.review)}
79
83
  isAriaDisabled={!canSubmit}
@@ -82,7 +86,7 @@ export const Footer = ({ canSubmit, onSave }) => {
82
86
  {__('Skip to review')}
83
87
  </Button>
84
88
  </Tooltip>
85
- <Button variant="link" onClick={onClose}>
89
+ <Button ouiaId="cancel-footer" variant="link" onClick={onClose}>
86
90
  {__('Cancel')}
87
91
  </Button>
88
92
  {isSubmitting && (