foreman_openscap 12.1.2 → 13.0.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: 7d8a562f0b445928773ee7d56fb55865ed3209a6d91b87c1ee8f39d414f1f772
4
- data.tar.gz: 3b30bb1362e2ce2cc5105cd54f84cc643f809026c207f17b9118ea5357a73f4b
3
+ metadata.gz: f58e733e3dd18596f3c7e16c4015103df7729a629577735ccb67ed7912172c3f
4
+ data.tar.gz: c87d597fcae523beb2f8607bc4a88bb77ee2508ce127b19761f6030d6eefd3e5
5
5
  SHA512:
6
- metadata.gz: 8dab6bff24fe0a6bf0a757f097f11dbcf598f55fa258bb973f018365ef42c574d579734caa9ec353ced159bb085adf77d49a3a45f1e5bfcfb19e7f219b5d7e00
7
- data.tar.gz: 3733b383bd767e0e606f2625904eae09899ec39aea038a6bae0ba4a45b127b71a9e16f25a571f107039db67d3e7cb67402cdb7156de9f3cec9a7dfb0eec26d6c
6
+ metadata.gz: 5a7eb9acc11cac66fc65081c49bb6bfb4cbe58fbc5accadf5896ba19af130d9237a8825215c6a866d68252dc51f5cdd28516b072494f8c969960b47e63221e1f
7
+ data.tar.gz: c68a53b00952b47560b977ebb6a661c24a3ab7a064f8ddfc05ba4a1c26e97882796db01556bd57fa705a6637a1df4073ff083716ff7909a9c11d1a333a9ac735
@@ -1,3 +1,18 @@
1
+ .compliance-host-policy-row {
2
+ display: flex;
3
+ flex-wrap: wrap;
4
+
5
+ > [class*='col-'] {
6
+ display: flex;
7
+ flex-direction: column;
8
+ }
9
+
10
+ .stats-well {
11
+ flex: 1 1 auto;
12
+ width: 100%;
13
+ }
14
+ }
15
+
1
16
  .scap-breakdown-chart {
2
17
  margin: 25px auto 12px;
3
18
  width: 240px;
@@ -43,11 +43,11 @@ module ForemanOpenscap
43
43
  def relevant?(options = {})
44
44
  # May fail host status during migration
45
45
  return false unless ForemanOpenscap::Asset.table_exists?
46
- host.policies.present?
46
+ host.combined_policies.present?
47
47
  end
48
48
 
49
49
  def to_status(options = {})
50
- latest_reports = host.policies.map { |p| host.last_report_for_policy p }.flatten
50
+ latest_reports = host.combined_policies.flat_map { |p| host.last_report_for_policy p }
51
51
  return INCOMPLIANT if latest_reports.any?(&:failed?)
52
52
  return INCONCLUSIVE if latest_reports.any?(&:othered?)
53
53
  COMPLIANT
@@ -1,5 +1,7 @@
1
- <% javascript 'foreman_openscap/scap_hosts_show' %>
2
1
  <% stylesheet 'foreman_openscap/scap_breakdown_chart' %>
2
+ <% content_for(:javascripts) do %>
3
+ <%= webpacked_plugins_js_for :foreman_openscap %>
4
+ <% end %>
3
5
 
4
6
  <%= breadcrumbs(:resource_url => api_hosts_path,
5
7
  :resource_filter => "is_compliance_host = true",
@@ -8,7 +10,7 @@
8
10
  <% title n_("%s compliance report by policy", "%s compliance reports by policy" , @host.combined_policies.length) % @host.to_label %>
9
11
  <% @host.combined_policies.each do |policy| %>
10
12
  <h2 class="center-block"><%= _('Policy %s') % policy %></h2>
11
- <div class="row">
13
+ <div class="row compliance-host-policy-row">
12
14
  <% data = ForemanOpenscap::HostReportDashboard::Data.new(policy, @host) %>
13
15
  <% if data.has_data? %>
14
16
  <div class="col-md-4">
@@ -26,7 +28,7 @@
26
28
  <div class="col-md-8">
27
29
  <div class="stats-well">
28
30
  <h4 class="ca"><%= _("%s reports over time") % policy %></h4>
29
- <%= react_component('LineChart', host_arf_reports_chart_data(policy.id)) %>
31
+ <%= react_component('OpenscapLineChart', host_arf_reports_chart_data(policy.id)) %>
30
32
  </div>
31
33
  </div>
32
34
  <% else %>
@@ -40,7 +40,7 @@ module ForemanOpenscap
40
40
  initializer 'foreman_openscap.register_plugin', :before => :finisher_hook do |app|
41
41
  app.reloader.to_prepare do
42
42
  Foreman::Plugin.register :foreman_openscap do
43
- requires_foreman '>= 3.17'
43
+ requires_foreman '>= 5.0'
44
44
  register_gettext
45
45
 
46
46
  apipie_documented_controllers ["#{ForemanOpenscap::Engine.root}/app/controllers/api/v2/compliance/*.rb"]
@@ -1,3 +1,3 @@
1
1
  module ForemanOpenscap
2
- VERSION = "12.1.2".freeze
2
+ VERSION = "13.0.0".freeze
3
3
  end
@@ -69,4 +69,54 @@ class ComplianceStatusTest < ActiveSupport::TestCase
69
69
  status.host = host
70
70
  assert_equal 0, status.to_status
71
71
  end
72
+
73
+ test 'relevant? should be true for host policies' do
74
+ status = ForemanOpenscap::ComplianceStatus.new
75
+ host = FactoryBot.create(:compliance_host, :policies => [@policy_a])
76
+
77
+ status.host = host
78
+
79
+ assert status.relevant?
80
+ end
81
+
82
+ test 'relevant? should be true for hostgroup policies and to_status should use them' do
83
+ status = ForemanOpenscap::ComplianceStatus.new
84
+ host = FactoryBot.create(:host, :with_hostgroup)
85
+ othered_status = { :passed => 0, :failed => 0, :othered => 1 }.with_indifferent_access
86
+
87
+ assert_empty host.policies
88
+ @policy_a.hostgroup_ids = [host.hostgroup.id]
89
+ assert @policy_a.save
90
+
91
+ host.reload
92
+ assert_includes host.combined_policies, @policy_a
93
+
94
+ report = FactoryBot.create(:arf_report, :host_id => host.id, :status => othered_status)
95
+ FactoryBot.create(:policy_arf_report, :policy_id => @policy_a.id, :arf_report_id => report.id)
96
+
97
+ status.host = host
98
+
99
+ assert status.relevant?
100
+ assert_equal ForemanOpenscap::ComplianceStatus::INCONCLUSIVE, status.to_status
101
+ end
102
+
103
+ test 'relevant? should be true for inherited parent hostgroup policies' do
104
+ status = ForemanOpenscap::ComplianceStatus.new
105
+ host = FactoryBot.create(:host, :with_hostgroup)
106
+ hostgroup = host.hostgroup
107
+
108
+ hostgroup.parent = FactoryBot.create(:hostgroup)
109
+ hostgroup.save!
110
+
111
+ assert_empty host.policies
112
+ @policy_a.hostgroup_ids = [hostgroup.parent.id]
113
+ assert @policy_a.save
114
+
115
+ host.reload
116
+ assert_includes host.combined_policies, @policy_a
117
+
118
+ status.host = host
119
+
120
+ assert status.relevant?
121
+ end
72
122
  end
@@ -0,0 +1,28 @@
1
+ export const data = [
2
+ ['red', [5, 7, 9], '#AA4643'],
3
+ ['green', [2, 4, 6], '#89A54E'],
4
+ ];
5
+
6
+ const dates = [1557014400000, 1559779200000, 1562457600000];
7
+
8
+ export const timeseriesData = [
9
+ ['Passed', [5, 7, 9], '#3f9c35'],
10
+ ['Failed', [2, 4, 6], '#c9190b'],
11
+ ['Othered', [1, 0, 2], '#f0ab00'],
12
+ ['dates', dates, null],
13
+ ];
14
+
15
+ export const emptyTimeseriesData = [
16
+ ['Passed', [0, 0, 0], '#3f9c35'],
17
+ ['Failed', [0, 0, 0], '#c9190b'],
18
+ ['Othered', [0, 0, 0], '#f0ab00'],
19
+ ['dates', dates, null],
20
+ ];
21
+
22
+ /** Spread-column format used by some Foreman chart fixtures. */
23
+ export const spreadTimeseriesData = [
24
+ ['Passed', 5, 7, 9, '#3f9c35'],
25
+ ['Failed', 2, 4, 6, '#c9190b'],
26
+ ['Othered', 1, 0, 2, '#f0ab00'],
27
+ ['dates', ...dates, null],
28
+ ];
@@ -0,0 +1,7 @@
1
+ .line-chart-container {
2
+ width: 100%;
3
+ height: 350px;
4
+ min-width: 200px;
5
+ min-height: 200px;
6
+ overflow: visible;
7
+ }
@@ -0,0 +1,266 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+
5
+ import {
6
+ formatAxisTick,
7
+ formatTooltipTitle,
8
+ getXAxisTickValues,
9
+ } from 'foremanReact/components/common/charts/helpers/LegendHelpers';
10
+
11
+ import LineChart from './index';
12
+ import {
13
+ data,
14
+ timeseriesData,
15
+ emptyTimeseriesData,
16
+ spreadTimeseriesData,
17
+ } from './LineChart.fixtures';
18
+ import {
19
+ processChartData,
20
+ hasChartData,
21
+ sanitizeChartDimension,
22
+ clampChartPadding,
23
+ getTimeseriesXDomain,
24
+ buildLineChartLegendData,
25
+ formatTooltipValue,
26
+ } from './LineChartHelpers';
27
+
28
+ jest.unmock('./');
29
+
30
+ const chartSize = { width: 800, height: 350 };
31
+
32
+ describe('hasChartData', () => {
33
+ it('returns false when all series values are zero', () => {
34
+ expect(hasChartData(emptyTimeseriesData, 'dates')).toBe(false);
35
+ });
36
+
37
+ it('returns true when any series has a non-zero value', () => {
38
+ expect(hasChartData(timeseriesData, 'dates')).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe('chart layout helpers', () => {
43
+ it('sanitizes invalid chart dimensions', () => {
44
+ expect(sanitizeChartDimension(Infinity, 350)).toBe(350);
45
+ expect(sanitizeChartDimension(NaN, 800)).toBe(800);
46
+ expect(sanitizeChartDimension(500, 800)).toBe(500);
47
+ });
48
+
49
+ it('clamps padding when it exceeds chart size', () => {
50
+ const padding = clampChartPadding(
51
+ { top: 50, bottom: 125, left: 300, right: 170 },
52
+ 400,
53
+ 350
54
+ );
55
+
56
+ expect(padding.left + padding.right).toBeLessThan(400);
57
+ expect(padding.top + padding.bottom).toBeLessThan(350);
58
+ });
59
+
60
+ it('expands degenerate timeseries x domains', () => {
61
+ const singlePointData = [
62
+ ['Passed', [5], '#3f9c35'],
63
+ ['dates', [1557014400000], null],
64
+ ];
65
+ const chartData = processChartData(singlePointData, 'dates', 'timeseries');
66
+ const domain = getTimeseriesXDomain(chartData);
67
+
68
+ expect(domain).toHaveLength(2);
69
+ expect(domain[0].getTime()).toBeLessThan(domain[1].getTime());
70
+ });
71
+ });
72
+
73
+ describe('buildLineChartLegendData', () => {
74
+ it('uses backend series colors for visible legend symbols', () => {
75
+ const chartData = processChartData(timeseriesData, 'dates', 'timeseries');
76
+ const legendData = buildLineChartLegendData(chartData, new Set());
77
+
78
+ expect(legendData).toEqual([
79
+ {
80
+ childName: 'Passed',
81
+ name: 'Passed',
82
+ symbol: { type: 'square', fill: '#3f9c35' },
83
+ },
84
+ {
85
+ childName: 'Failed',
86
+ name: 'Failed',
87
+ symbol: { type: 'square', fill: '#c9190b' },
88
+ },
89
+ {
90
+ childName: 'Othered',
91
+ name: 'Othered',
92
+ symbol: { type: 'square', fill: '#f0ab00' },
93
+ },
94
+ ]);
95
+ });
96
+
97
+ it('uses eyeSlash symbol for hidden series', () => {
98
+ const chartData = processChartData(timeseriesData, 'dates', 'timeseries');
99
+ const legendData = buildLineChartLegendData(chartData, new Set(['Failed']));
100
+
101
+ expect(legendData[1]).toMatchObject({
102
+ childName: 'Failed',
103
+ name: 'Failed',
104
+ symbol: { type: 'eyeSlash' },
105
+ });
106
+ expect(legendData[1].symbol.fill).toBeDefined();
107
+ });
108
+ });
109
+
110
+ describe('formatTooltipValue', () => {
111
+ it('formats rule counts as whole numbers', () => {
112
+ expect(formatTooltipValue(10)).toBe('10');
113
+ expect(formatTooltipValue(10.0)).toBe('10');
114
+ expect(formatTooltipValue(0)).toBe('0');
115
+ });
116
+
117
+ it('rounds non-integer values', () => {
118
+ expect(formatTooltipValue(6.7)).toBe('7');
119
+ });
120
+ });
121
+
122
+ describe('LineChart', () => {
123
+ it('renders chart with regular data', () => {
124
+ const { container } = render(
125
+ <LineChart data={data} config="regular" size={chartSize} />
126
+ );
127
+
128
+ expect(container.querySelector('svg')).toBeInTheDocument();
129
+ });
130
+
131
+ it('renders chart with timeseries data', () => {
132
+ const { container } = render(
133
+ <LineChart
134
+ data={timeseriesData}
135
+ config="timeseries"
136
+ xAxisDataLabel="dates"
137
+ size={chartSize}
138
+ />
139
+ );
140
+
141
+ expect(container.querySelector('svg')).toBeInTheDocument();
142
+ });
143
+
144
+ it('renders chart with spread-column timeseries data', () => {
145
+ const { container } = render(
146
+ <LineChart
147
+ data={spreadTimeseriesData}
148
+ config="timeseries"
149
+ xAxisDataLabel="dates"
150
+ size={chartSize}
151
+ />
152
+ );
153
+
154
+ expect(container.querySelector('svg')).toBeInTheDocument();
155
+ });
156
+
157
+ it('renders empty state when no data provided', () => {
158
+ const { container } = render(<LineChart data={null} />);
159
+
160
+ expect(screen.getByText('No data available')).toBeInTheDocument();
161
+ expect(
162
+ container.querySelector('.line-chart-container')
163
+ ).not.toBeInTheDocument();
164
+ });
165
+
166
+ it('renders empty state when all values are zero', () => {
167
+ const { container } = render(
168
+ <LineChart
169
+ data={emptyTimeseriesData}
170
+ config="timeseries"
171
+ xAxisDataLabel="dates"
172
+ />
173
+ );
174
+
175
+ expect(screen.getByText('No data available')).toBeInTheDocument();
176
+ expect(
177
+ container.querySelector('.line-chart-container')
178
+ ).not.toBeInTheDocument();
179
+ });
180
+
181
+ it('displays custom noDataMsg', () => {
182
+ const customMsg = 'Custom no data message';
183
+ render(<LineChart data={null} noDataMsg={customMsg} />);
184
+
185
+ expect(screen.getByText(customMsg)).toBeInTheDocument();
186
+ });
187
+
188
+ it('renders legend items for each series', () => {
189
+ const { container } = render(
190
+ <LineChart
191
+ data={timeseriesData}
192
+ config="timeseries"
193
+ xAxisDataLabel="dates"
194
+ size={chartSize}
195
+ />
196
+ );
197
+
198
+ expect(container.textContent).toContain('Passed');
199
+ expect(container.textContent).toContain('Failed');
200
+ expect(container.textContent).toContain('Othered');
201
+ });
202
+
203
+ it('legend items are interactive (button role for accessibility)', () => {
204
+ render(
205
+ <LineChart
206
+ data={timeseriesData}
207
+ config="timeseries"
208
+ xAxisDataLabel="dates"
209
+ size={chartSize}
210
+ />
211
+ );
212
+
213
+ expect(
214
+ screen.getAllByRole('button', { name: 'Passed' }).length
215
+ ).toBeGreaterThan(0);
216
+ });
217
+
218
+ it('clicking legend toggles series visibility', () => {
219
+ const { container } = render(
220
+ <LineChart
221
+ data={timeseriesData}
222
+ config="timeseries"
223
+ xAxisDataLabel="dates"
224
+ size={chartSize}
225
+ />
226
+ );
227
+
228
+ const labels = container.querySelectorAll('.chart-legend-label');
229
+ expect(labels.length).toBeGreaterThan(0);
230
+
231
+ const firstLabel = labels[0];
232
+ expect(firstLabel).toHaveAttribute('data-hidden', 'false');
233
+
234
+ fireEvent.click(firstLabel);
235
+ expect(firstLabel).toHaveAttribute('data-hidden', 'true');
236
+
237
+ fireEvent.click(firstLabel);
238
+ expect(firstLabel).toHaveAttribute('data-hidden', 'false');
239
+ });
240
+
241
+ it('formats tooltip title for timeseries data points', () => {
242
+ const chartData = processChartData(timeseriesData, 'dates', 'timeseries');
243
+ const title = formatTooltipTitle(chartData[0].data[0]);
244
+
245
+ expect(title).toMatch(/\d/);
246
+ expect(title).toContain(',');
247
+ });
248
+
249
+ it('does not show duplicate x-axis labels when multiple data points fall within the same minute', () => {
250
+ const denseData = [
251
+ ['Passed', [1, 2, 3], '#3f9c35'],
252
+ ['Failed', [1, 2, 3], '#c9190b'],
253
+ ['dates', [1614449768000, 1614449769000, 1614451500000], null],
254
+ ];
255
+ const chartData = processChartData(denseData, 'dates', 'timeseries');
256
+ const tickValues = getXAxisTickValues(chartData, 6);
257
+
258
+ expect(tickValues).toBeDefined();
259
+ expect(tickValues.length).toBeGreaterThan(0);
260
+
261
+ const formattedLabels = tickValues.map(t => formatAxisTick(t));
262
+ const uniqueLabels = [...new Set(formattedLabels)];
263
+
264
+ expect(formattedLabels).toHaveLength(uniqueLabels.length);
265
+ });
266
+ });
@@ -0,0 +1,190 @@
1
+ /** Backend sends [label, values, color]; legacy Foreman charts used spread columns. */
2
+ import { chart_color_black_500 as chartColorBlack500 } from '@patternfly/react-tokens';
3
+
4
+ const getColumnValues = col => {
5
+ if (Array.isArray(col[1])) return col[1];
6
+
7
+ const values = col.slice(1);
8
+ const last = values[values.length - 1];
9
+
10
+ if (
11
+ values.length > 1 &&
12
+ (last == null || (typeof last === 'string' && last.startsWith('#')))
13
+ ) {
14
+ return values.slice(0, -1);
15
+ }
16
+
17
+ return values;
18
+ };
19
+
20
+ const getSeriesColor = col => {
21
+ if (typeof col[2] === 'string' && col[2].startsWith('#')) {
22
+ return col[2];
23
+ }
24
+
25
+ const last = col[col.length - 1];
26
+ if (typeof last === 'string' && last.startsWith('#')) {
27
+ return last;
28
+ }
29
+
30
+ return undefined;
31
+ };
32
+
33
+ const toMs = val => {
34
+ const n = Number(val);
35
+ return n >= 1e12 ? n : n * 1000;
36
+ };
37
+
38
+ /** Process raw backend data into chart series for PatternFly line charts. */
39
+ export const processChartData = (data, xAxisDataLabel, config) => {
40
+ if (!data || data.length === 0) return null;
41
+
42
+ if (config === 'timeseries') {
43
+ const timeColumn = data.find(col => col[0] === xAxisDataLabel);
44
+ if (!timeColumn) return null;
45
+
46
+ const xValues = getColumnValues(timeColumn).map(t => toMs(t));
47
+
48
+ const series = data
49
+ .filter(col => col[0] !== xAxisDataLabel)
50
+ .map(col => {
51
+ const values = getColumnValues(col);
52
+ return {
53
+ name: col[0],
54
+ color: getSeriesColor(col),
55
+ data: values.map((value, index) => ({
56
+ x: new Date(xValues[index]),
57
+ y: value ?? 0,
58
+ name: col[0],
59
+ })),
60
+ };
61
+ });
62
+
63
+ return series.length > 0 ? series : null;
64
+ }
65
+
66
+ const series = data.map(col => {
67
+ const values = getColumnValues(col);
68
+ return {
69
+ name: col[0],
70
+ color: getSeriesColor(col),
71
+ data: values.map((value, index) => ({
72
+ x: index + 1,
73
+ y: value ?? 0,
74
+ name: col[0],
75
+ })),
76
+ };
77
+ });
78
+
79
+ return series.some(s => s.data.length > 0) ? series : null;
80
+ };
81
+
82
+ export const hasChartData = (data, xAxisDataLabel) => {
83
+ if (!data || data.length === 0) return false;
84
+
85
+ return data
86
+ .filter(col => col[0] !== xAxisDataLabel)
87
+ .some(col =>
88
+ getColumnValues(col).some(value => value !== 0 && value != null)
89
+ );
90
+ };
91
+
92
+ export const getYTickValues = (chartData, hiddenSeries = new Set()) => {
93
+ if (!chartData?.length) return undefined;
94
+
95
+ let maxY = 0;
96
+ chartData
97
+ .filter(chartSeries => !hiddenSeries.has(chartSeries.name))
98
+ .forEach(chartSeries => {
99
+ chartSeries.data.forEach(point => {
100
+ maxY = Math.max(maxY, point.y ?? 0);
101
+ });
102
+ });
103
+
104
+ if (maxY <= 0) return [0, 0.5, 1.0];
105
+
106
+ const step = Math.max(0.1, Math.ceil((maxY / 4) * 10) / 10);
107
+ return [0, 1, 2, 3, 4, 5].map(i => Math.round(i * step * 10) / 10);
108
+ };
109
+
110
+ /** Rule counts are whole numbers; avoid decimal formatting in tooltips. */
111
+ export const formatTooltipValue = value => {
112
+ const num = Number(value);
113
+ if (!Number.isFinite(num)) return '';
114
+ if (Math.abs(num) >= 1e21) {
115
+ return num.toExponential(1);
116
+ }
117
+ return String(Math.round(num));
118
+ };
119
+
120
+ export const sanitizeChartDimension = (value, fallback) => {
121
+ const n = Number(value);
122
+ return Number.isFinite(n) && n > 0 ? n : fallback;
123
+ };
124
+
125
+ /** Prevent padding from exceeding chart size, which breaks Victory scale/range math. */
126
+ export const clampChartPadding = (padding, width, height) => {
127
+ let { top = 0, bottom = 0, left = 0, right = 0 } = padding;
128
+
129
+ const horizontalTotal = left + right;
130
+ if (horizontalTotal >= width) {
131
+ const scale = (width * 0.9) / horizontalTotal;
132
+ left *= scale;
133
+ right *= scale;
134
+ }
135
+
136
+ const verticalTotal = top + bottom;
137
+ if (verticalTotal >= height) {
138
+ const scale = (height * 0.9) / verticalTotal;
139
+ top *= scale;
140
+ bottom *= scale;
141
+ }
142
+
143
+ return { top, bottom, left, right };
144
+ };
145
+
146
+ /** Expand degenerate time domains so Victory can draw valid line paths. */
147
+ export const getTimeseriesXDomain = chartData => {
148
+ if (!chartData?.[0]?.data?.length) return undefined;
149
+
150
+ const times = chartData[0].data.map(point =>
151
+ point.x instanceof Date ? point.x.getTime() : new Date(point.x).getTime()
152
+ );
153
+ const min = Math.min(...times);
154
+ const max = Math.max(...times);
155
+
156
+ if (min === max) {
157
+ const offset = 12 * 60 * 60 * 1000;
158
+ return [new Date(min - offset), new Date(max + offset)];
159
+ }
160
+
161
+ return undefined;
162
+ };
163
+
164
+ /** Legend swatches must use backend series colors, not the default PF theme scale. */
165
+ export const buildLineChartLegendData = (chartData, hiddenSeries) =>
166
+ chartData.map(series => {
167
+ const hidden = hiddenSeries.has(series.name);
168
+
169
+ if (hidden) {
170
+ return {
171
+ childName: series.name,
172
+ name: series.name,
173
+ symbol: {
174
+ type: 'eyeSlash',
175
+ fill: chartColorBlack500.var,
176
+ },
177
+ };
178
+ }
179
+
180
+ return {
181
+ childName: series.name,
182
+ name: series.name,
183
+ ...(series.color && {
184
+ symbol: {
185
+ type: 'square',
186
+ fill: series.color,
187
+ },
188
+ }),
189
+ };
190
+ });
@@ -0,0 +1,324 @@
1
+ import React, {
2
+ useMemo,
3
+ useRef,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useState,
7
+ useCallback,
8
+ } from 'react';
9
+ import PropTypes from 'prop-types';
10
+ import {
11
+ Chart,
12
+ ChartAxis,
13
+ ChartGroup,
14
+ ChartLine,
15
+ ChartLegend,
16
+ ChartLegendTooltip,
17
+ ChartThemeColor,
18
+ createContainer,
19
+ } from '@patternfly/react-charts';
20
+
21
+ import { translate as __ } from 'foremanReact/common/I18n';
22
+ import {
23
+ formatAxisTick,
24
+ formatTooltipTitle,
25
+ formatYAxisTick,
26
+ getLegendEvents,
27
+ getSeriesOpacity,
28
+ getXAxisTickValues,
29
+ InteractiveLegendLabel,
30
+ InteractiveLegendSymbol,
31
+ XAxisTickLabel,
32
+ } from 'foremanReact/components/common/charts/helpers/LegendHelpers';
33
+ import EmptyState from '../EmptyState';
34
+
35
+ import {
36
+ processChartData,
37
+ hasChartData,
38
+ getYTickValues,
39
+ sanitizeChartDimension,
40
+ clampChartPadding,
41
+ getTimeseriesXDomain,
42
+ buildLineChartLegendData,
43
+ formatTooltipValue,
44
+ } from './LineChartHelpers';
45
+ import './LineChart.scss';
46
+
47
+ const DEFAULT_HEIGHT = 350;
48
+ const DEFAULT_WIDTH = 1000;
49
+
50
+ /** Match AreaChart padding so angled x-axis tick labels are not clipped. */
51
+ const CHART_PADDING_BASE = { bottom: 125, right: 170, top: 50 };
52
+
53
+ const getDataClickEvents = onclick => {
54
+ if (!onclick) return null;
55
+
56
+ return {
57
+ target: 'data',
58
+ eventHandlers: {
59
+ onClick: () => [
60
+ {
61
+ target: 'data',
62
+ mutation: props => {
63
+ if (props.datum?.name) {
64
+ onclick({ id: props.datum.name });
65
+ }
66
+ return null;
67
+ },
68
+ },
69
+ ],
70
+ },
71
+ };
72
+ };
73
+
74
+ const LineChart = ({
75
+ data,
76
+ config,
77
+ noDataMsg,
78
+ xAxisDataLabel,
79
+ onclick,
80
+ id,
81
+ size,
82
+ title: _title,
83
+ unloadData: _unloadData,
84
+ axisOpts: _axisOpts,
85
+ }) => {
86
+ const chartData = useMemo(
87
+ () => processChartData(data, xAxisDataLabel, config),
88
+ [data, xAxisDataLabel, config]
89
+ );
90
+
91
+ const CursorVoronoiContainer = useMemo(
92
+ () => createContainer('voronoi', 'cursor'),
93
+ []
94
+ );
95
+
96
+ const [hiddenSeries, setHiddenSeries] = useState(() => new Set());
97
+ const [hoveredSeries, setHoveredSeries] = useState(null);
98
+ const toggleSeries = useCallback(name => {
99
+ setHiddenSeries(prev => {
100
+ const next = new Set(prev);
101
+ if (next.has(name)) next.delete(name);
102
+ else next.add(name);
103
+ return next;
104
+ });
105
+ }, []);
106
+
107
+ const containerRef = useRef(null);
108
+ const [observedSize, setObservedSize] = useState(null);
109
+
110
+ const updateObservedSize = useCallback(() => {
111
+ const el = containerRef.current;
112
+ if (!el) return;
113
+
114
+ const { width, height } = el.getBoundingClientRect();
115
+ if (width > 0 && height > 0) {
116
+ setObservedSize({ width, height });
117
+ }
118
+ }, []);
119
+
120
+ useLayoutEffect(() => {
121
+ updateObservedSize();
122
+ }, [updateObservedSize]);
123
+
124
+ useEffect(() => {
125
+ const el = containerRef.current;
126
+ if (!el || typeof ResizeObserver === 'undefined') return undefined;
127
+
128
+ const observer = new ResizeObserver(() => {
129
+ updateObservedSize();
130
+ });
131
+
132
+ observer.observe(el);
133
+ return () => observer.disconnect();
134
+ }, [updateObservedSize]);
135
+
136
+ const legendData = useMemo(
137
+ () => (chartData ? buildLineChartLegendData(chartData, hiddenSeries) : []),
138
+ [chartData, hiddenSeries]
139
+ );
140
+
141
+ const visibleSeries = useMemo(
142
+ () => chartData?.filter(series => !hiddenSeries.has(series.name)) ?? [],
143
+ [chartData, hiddenSeries]
144
+ );
145
+
146
+ const tickValues = useMemo(() => getXAxisTickValues(chartData, 6), [
147
+ chartData,
148
+ ]);
149
+ const yTickValues = useMemo(() => getYTickValues(chartData, hiddenSeries), [
150
+ chartData,
151
+ hiddenSeries,
152
+ ]);
153
+ const timeseriesXDomain = useMemo(
154
+ () =>
155
+ config === 'timeseries' ? getTimeseriesXDomain(chartData) : undefined,
156
+ [chartData, config]
157
+ );
158
+
159
+ if (!hasChartData(data, xAxisDataLabel) || !chartData) {
160
+ return (
161
+ <EmptyState
162
+ ouiaEmptyStateTitleId="openscap-line-chart-empty-state-title"
163
+ title={noDataMsg}
164
+ />
165
+ );
166
+ }
167
+
168
+ const hasExplicitSize = size?.width > 0 && size?.height > 0;
169
+ if (!hasExplicitSize && !observedSize) {
170
+ return <div ref={containerRef} className="line-chart-container" />;
171
+ }
172
+
173
+ const maxTickLabelLen =
174
+ yTickValues?.length > 0
175
+ ? Math.max(...yTickValues.map(t => formatYAxisTick(t).length))
176
+ : formatYAxisTick(0).length;
177
+ const dynamicLeft = maxTickLabelLen * 8 + 50;
178
+ const chartHeight = sanitizeChartDimension(
179
+ size?.height ?? observedSize?.height,
180
+ DEFAULT_HEIGHT
181
+ );
182
+ const chartWidth = sanitizeChartDimension(
183
+ size?.width ?? observedSize?.width,
184
+ DEFAULT_WIDTH
185
+ );
186
+ const padding = clampChartPadding(
187
+ {
188
+ ...CHART_PADDING_BASE,
189
+ left: dynamicLeft,
190
+ },
191
+ chartWidth,
192
+ chartHeight
193
+ );
194
+ const chartName = id || 'line-chart';
195
+ const legendEvents = getLegendEvents(chartName, toggleSeries);
196
+ const events = [getDataClickEvents(onclick), legendEvents].filter(Boolean);
197
+
198
+ return (
199
+ <div ref={containerRef} className="line-chart-container">
200
+ <Chart
201
+ name={chartName}
202
+ ariaDesc={__('Line chart')}
203
+ themeColor={ChartThemeColor.multi}
204
+ animate={false}
205
+ domainPadding={{ x: [20, 20], y: [10, 10] }}
206
+ scale={config === 'timeseries' ? { x: 'time' } : undefined}
207
+ containerComponent={
208
+ <CursorVoronoiContainer
209
+ cursorDimension="x"
210
+ labels={({ datum }) => formatTooltipValue(datum.y)}
211
+ labelComponent={
212
+ <ChartLegendTooltip
213
+ legendData={legendData}
214
+ title={formatTooltipTitle}
215
+ />
216
+ }
217
+ mouseFollowTooltips
218
+ voronoiDimension="x"
219
+ voronoiPadding={padding}
220
+ constrainToVisibleArea
221
+ />
222
+ }
223
+ height={chartHeight}
224
+ width={chartWidth}
225
+ padding={padding}
226
+ legendData={legendData}
227
+ legendOrientation="vertical"
228
+ legendPosition="right"
229
+ legendComponent={
230
+ <ChartLegend
231
+ dataComponent={
232
+ <InteractiveLegendSymbol
233
+ setHoveredSeries={setHoveredSeries}
234
+ toggleSeries={toggleSeries}
235
+ />
236
+ }
237
+ labelComponent={
238
+ <InteractiveLegendLabel
239
+ hiddenSeries={hiddenSeries}
240
+ hoveredSeries={hoveredSeries}
241
+ setHoveredSeries={setHoveredSeries}
242
+ toggleSeries={toggleSeries}
243
+ />
244
+ }
245
+ />
246
+ }
247
+ events={events}
248
+ >
249
+ <ChartAxis
250
+ tickValues={config === 'timeseries' ? tickValues : undefined}
251
+ tickFormat={config === 'timeseries' ? formatAxisTick : undefined}
252
+ domain={timeseriesXDomain}
253
+ tickLabelComponent={
254
+ config === 'timeseries' ? (
255
+ <XAxisTickLabel yAxisLabelOffset={-12} />
256
+ ) : (
257
+ undefined
258
+ )
259
+ }
260
+ style={
261
+ config === 'timeseries'
262
+ ? { tickLabels: { angle: -45, verticalAnchor: 'end' } }
263
+ : undefined
264
+ }
265
+ />
266
+ <ChartAxis
267
+ dependentAxis
268
+ showGrid
269
+ tickValues={yTickValues}
270
+ tickFormat={formatYAxisTick}
271
+ />
272
+ <ChartGroup>
273
+ {visibleSeries.map(series => (
274
+ <ChartLine
275
+ key={series.name}
276
+ name={series.name}
277
+ data={series.data}
278
+ style={{
279
+ data: {
280
+ ...(series.color && { stroke: series.color }),
281
+ opacity: getSeriesOpacity(
282
+ hoveredSeries && hoveredSeries !== series.name
283
+ ),
284
+ },
285
+ }}
286
+ />
287
+ ))}
288
+ </ChartGroup>
289
+ </Chart>
290
+ </div>
291
+ );
292
+ };
293
+
294
+ LineChart.propTypes = {
295
+ data: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
296
+ config: PropTypes.oneOf(['regular', 'timeseries']),
297
+ noDataMsg: PropTypes.string,
298
+ xAxisDataLabel: PropTypes.string,
299
+ onclick: PropTypes.func,
300
+ id: PropTypes.string,
301
+ size: PropTypes.shape({
302
+ height: PropTypes.number,
303
+ width: PropTypes.number,
304
+ }),
305
+ // Accepted for compatibility with the legacy Foreman wrapper; unused in PF5.
306
+ title: PropTypes.object,
307
+ unloadData: PropTypes.bool,
308
+ axisOpts: PropTypes.object,
309
+ };
310
+
311
+ LineChart.defaultProps = {
312
+ data: undefined,
313
+ config: 'regular',
314
+ noDataMsg: __('No data available'),
315
+ xAxisDataLabel: '',
316
+ onclick: undefined,
317
+ id: undefined,
318
+ size: undefined,
319
+ title: { type: 'percent' },
320
+ unloadData: false,
321
+ axisOpts: {},
322
+ };
323
+
324
+ export default LineChart;
data/webpack/index.js CHANGED
@@ -2,10 +2,12 @@ import componentRegistry from 'foremanReact/components/componentRegistry';
2
2
 
3
3
  import RuleSeverity from './components/RuleSeverity';
4
4
  import OpenscapRemediationWizard from './components/OpenscapRemediationWizard';
5
+ import LineChart from './components/LineChart';
5
6
 
6
7
  const components = [
7
8
  { name: 'RuleSeverity', type: RuleSeverity },
8
9
  { name: 'OpenscapRemediationWizard', type: OpenscapRemediationWizard },
10
+ { name: 'OpenscapLineChart', type: LineChart },
9
11
  ];
10
12
 
11
13
  components.forEach(component => {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_openscap
3
3
  version: !ruby/object:Gem::Version
4
- version: 12.1.2
4
+ version: 13.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - slukasik@redhat.com
@@ -40,7 +40,6 @@ files:
40
40
  - app/assets/javascripts/foreman_openscap/policy_dashboard.js
41
41
  - app/assets/javascripts/foreman_openscap/policy_edit.js
42
42
  - app/assets/javascripts/foreman_openscap/reports.js
43
- - app/assets/javascripts/foreman_openscap/scap_hosts_show.js
44
43
  - app/assets/stylesheets/foreman_openscap/policy.css
45
44
  - app/assets/stylesheets/foreman_openscap/policy_dashboard.css
46
45
  - app/assets/stylesheets/foreman_openscap/reports.css
@@ -371,6 +370,11 @@ files:
371
370
  - webpack/components/IndexLayout.scss
372
371
  - webpack/components/IndexTable/IndexTableHelper.js
373
372
  - webpack/components/IndexTable/index.js
373
+ - webpack/components/LineChart/LineChart.fixtures.js
374
+ - webpack/components/LineChart/LineChart.scss
375
+ - webpack/components/LineChart/LineChart.test.js
376
+ - webpack/components/LineChart/LineChartHelpers.js
377
+ - webpack/components/LineChart/index.js
374
378
  - webpack/components/LinkButton.js
375
379
  - webpack/components/OpenscapRemediationWizard/Footer.js
376
380
  - webpack/components/OpenscapRemediationWizard/OpenscapRemediationSelectors.js
@@ -1,4 +0,0 @@
1
- $(function() {
2
- // Not sure about this ugly hack.
3
- $('.col-md-4 .stats-well').height($('.col-md-8 .stats-well').height());
4
- });