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 +4 -4
- data/app/assets/stylesheets/foreman_openscap/scap_breakdown_chart.css +15 -0
- data/app/models/foreman_openscap/compliance_status.rb +2 -2
- data/app/views/compliance_hosts/show.html.erb +5 -3
- data/lib/foreman_openscap/engine.rb +1 -1
- data/lib/foreman_openscap/version.rb +1 -1
- data/test/unit/compliance_status_test.rb +50 -0
- data/webpack/components/LineChart/LineChart.fixtures.js +28 -0
- data/webpack/components/LineChart/LineChart.scss +7 -0
- data/webpack/components/LineChart/LineChart.test.js +266 -0
- data/webpack/components/LineChart/LineChartHelpers.js +190 -0
- data/webpack/components/LineChart/index.js +324 -0
- data/webpack/index.js +2 -0
- metadata +6 -2
- data/app/assets/javascripts/foreman_openscap/scap_hosts_show.js +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f58e733e3dd18596f3c7e16c4015103df7729a629577735ccb67ed7912172c3f
|
|
4
|
+
data.tar.gz: c87d597fcae523beb2f8607bc4a88bb77ee2508ce127b19761f6030d6eefd3e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
46
|
+
host.combined_policies.present?
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def to_status(options = {})
|
|
50
|
-
latest_reports = host.
|
|
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('
|
|
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 '>=
|
|
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"]
|
|
@@ -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,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:
|
|
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
|