foreman_openscap 12.1.2 → 13.0.1

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/foreman_openscap/scap_breakdown_chart.css +15 -0
  3. data/app/models/foreman_openscap/compliance_status.rb +2 -2
  4. data/app/views/compliance_hosts/show.html.erb +5 -3
  5. data/lib/foreman_openscap/engine.rb +1 -1
  6. data/lib/foreman_openscap/version.rb +1 -1
  7. data/test/unit/compliance_status_test.rb +50 -0
  8. data/webpack/components/LineChart/LineChart.fixtures.js +28 -0
  9. data/webpack/components/LineChart/LineChart.scss +7 -0
  10. data/webpack/components/LineChart/LineChart.test.js +266 -0
  11. data/webpack/components/LineChart/LineChartHelpers.js +206 -0
  12. data/webpack/components/LineChart/index.js +324 -0
  13. data/webpack/components/OpenscapRemediationWizard/constants.js +3 -0
  14. data/webpack/components/OpenscapRemediationWizard/steps/Finish.js +2 -2
  15. data/webpack/components/OpenscapRemediationWizard/steps/ReviewRemediation.js +10 -2
  16. data/webpack/components/OpenscapRemediationWizard/steps/SnippetSelect.js +3 -1
  17. data/webpack/global_index.js +3 -1
  18. data/webpack/index.js +2 -0
  19. data/webpack/test_setup.js +1 -0
  20. metadata +8 -19
  21. data/app/assets/javascripts/foreman_openscap/scap_hosts_show.js +0 -4
  22. data/webpack/components/ConfirmModal.js +0 -66
  23. data/webpack/components/ConfirmModal.scss +0 -3
  24. data/webpack/components/IndexLayout.js +0 -44
  25. data/webpack/components/IndexTable/IndexTableHelper.js +0 -6
  26. data/webpack/components/IndexTable/index.js +0 -73
  27. data/webpack/components/LinkButton.js +0 -42
  28. data/webpack/components/withDeleteModal.js +0 -51
  29. data/webpack/components/withLoading.js +0 -107
  30. data/webpack/helpers/commonHelper.js +0 -1
  31. data/webpack/helpers/globalIdHelper.js +0 -15
  32. data/webpack/helpers/mutationHelper.js +0 -68
  33. data/webpack/helpers/pageParamsHelper.js +0 -31
  34. data/webpack/helpers/permissionsHelper.js +0 -42
  35. data/webpack/helpers/tableHelper.js +0 -9
  36. data/webpack/helpers/toastHelper.js +0 -3
  37. data/webpack/testHelper.js +0 -127
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d8a562f0b445928773ee7d56fb55865ed3209a6d91b87c1ee8f39d414f1f772
4
- data.tar.gz: 3b30bb1362e2ce2cc5105cd54f84cc643f809026c207f17b9118ea5357a73f4b
3
+ metadata.gz: 31e3721d3419a9695bb06d27b4dc1f79daf7aa825fe091afdd5ce3ab84fec6d5
4
+ data.tar.gz: '09f41b5c13f636a9227ace3b2e2b7c27f5341d22400d98afdcddc597c5650af6'
5
5
  SHA512:
6
- metadata.gz: 8dab6bff24fe0a6bf0a757f097f11dbcf598f55fa258bb973f018365ef42c574d579734caa9ec353ced159bb085adf77d49a3a45f1e5bfcfb19e7f219b5d7e00
7
- data.tar.gz: 3733b383bd767e0e606f2625904eae09899ec39aea038a6bae0ba4a45b127b71a9e16f25a571f107039db67d3e7cb67402cdb7156de9f3cec9a7dfb0eec26d6c
6
+ metadata.gz: 508fba001dca06e61ae108a10ca54193eec9ab3ba4f26b310481713a852aef69d09c720fdd44ea8ecf89a741a906c30b415640497894f4e0da92b14f0bb5de15
7
+ data.tar.gz: ffcc23a0d7bd9ecf624ee81f6e46eadd02fc1bf9111e8bd15648590e9f435a7872e6791f2197d024f680628bdf9de5f6e602c5b13f156984130b2865ad381d34
@@ -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.1".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,206 @@
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
+ import {
4
+ MS_PER_SECOND,
5
+ SECONDS_PER_MINUTE,
6
+ MINUTES_PER_HOUR,
7
+ HOURS_PER_HALF_DAY,
8
+ } from 'foremanReact/constants';
9
+
10
+ const TIMESTAMP_THRESHOLD = 1e12;
11
+ const DEFAULT_TICK_MID = 0.5;
12
+ const MIN_TICK_STEP = 0.1;
13
+ const EXPONENTIAL_THRESHOLD = 1e21;
14
+ const CLAMP_SCALE_FACTOR = 0.9;
15
+
16
+ const getColumnValues = col => {
17
+ if (Array.isArray(col[1])) return col[1];
18
+
19
+ const values = col.slice(1);
20
+ const last = values[values.length - 1];
21
+
22
+ if (
23
+ values.length > 1 &&
24
+ (last == null || (typeof last === 'string' && last.startsWith('#')))
25
+ ) {
26
+ return values.slice(0, -1);
27
+ }
28
+
29
+ return values;
30
+ };
31
+
32
+ const getSeriesColor = col => {
33
+ if (typeof col[2] === 'string' && col[2].startsWith('#')) {
34
+ return col[2];
35
+ }
36
+
37
+ const last = col[col.length - 1];
38
+ if (typeof last === 'string' && last.startsWith('#')) {
39
+ return last;
40
+ }
41
+
42
+ return undefined;
43
+ };
44
+
45
+ const toMs = val => {
46
+ const n = Number(val);
47
+ return n >= TIMESTAMP_THRESHOLD ? n : n * MS_PER_SECOND;
48
+ };
49
+
50
+ /** Process raw backend data into chart series for PatternFly line charts. */
51
+ export const processChartData = (data, xAxisDataLabel, config) => {
52
+ if (!data || data.length === 0) return null;
53
+
54
+ if (config === 'timeseries') {
55
+ const timeColumn = data.find(col => col[0] === xAxisDataLabel);
56
+ if (!timeColumn) return null;
57
+
58
+ const xValues = getColumnValues(timeColumn).map(t => toMs(t));
59
+
60
+ const series = data
61
+ .filter(col => col[0] !== xAxisDataLabel)
62
+ .map(col => {
63
+ const values = getColumnValues(col);
64
+ return {
65
+ name: col[0],
66
+ color: getSeriesColor(col),
67
+ data: values.map((value, index) => ({
68
+ x: new Date(xValues[index]),
69
+ y: value ?? 0,
70
+ name: col[0],
71
+ })),
72
+ };
73
+ });
74
+
75
+ return series.length > 0 ? series : null;
76
+ }
77
+
78
+ const series = data.map(col => {
79
+ const values = getColumnValues(col);
80
+ return {
81
+ name: col[0],
82
+ color: getSeriesColor(col),
83
+ data: values.map((value, index) => ({
84
+ x: index + 1,
85
+ y: value ?? 0,
86
+ name: col[0],
87
+ })),
88
+ };
89
+ });
90
+
91
+ return series.some(s => s.data.length > 0) ? series : null;
92
+ };
93
+
94
+ export const hasChartData = (data, xAxisDataLabel) => {
95
+ if (!data || data.length === 0) return false;
96
+
97
+ return data
98
+ .filter(col => col[0] !== xAxisDataLabel)
99
+ .some(col =>
100
+ getColumnValues(col).some(value => value !== 0 && value != null)
101
+ );
102
+ };
103
+
104
+ export const getYTickValues = (chartData, hiddenSeries = new Set()) => {
105
+ if (!chartData?.length) return undefined;
106
+
107
+ let maxY = 0;
108
+ chartData
109
+ .filter(chartSeries => !hiddenSeries.has(chartSeries.name))
110
+ .forEach(chartSeries => {
111
+ chartSeries.data.forEach(point => {
112
+ maxY = Math.max(maxY, point.y ?? 0);
113
+ });
114
+ });
115
+
116
+ if (maxY <= 0) return [0, DEFAULT_TICK_MID, 1.0];
117
+
118
+ const step = Math.max(MIN_TICK_STEP, Math.ceil((maxY / 4) * 10) / 10);
119
+ return [0, 1, 2, 3, 4, 5].map(i => Math.round(i * step * 10) / 10);
120
+ };
121
+
122
+ /** Rule counts are whole numbers; avoid decimal formatting in tooltips. */
123
+ export const formatTooltipValue = value => {
124
+ const num = Number(value);
125
+ if (!Number.isFinite(num)) return '';
126
+ if (Math.abs(num) >= EXPONENTIAL_THRESHOLD) {
127
+ return num.toExponential(1);
128
+ }
129
+ return String(Math.round(num));
130
+ };
131
+
132
+ export const sanitizeChartDimension = (value, fallback) => {
133
+ const n = Number(value);
134
+ return Number.isFinite(n) && n > 0 ? n : fallback;
135
+ };
136
+
137
+ /** Prevent padding from exceeding chart size, which breaks Victory scale/range math. */
138
+ export const clampChartPadding = (padding, width, height) => {
139
+ let { top = 0, bottom = 0, left = 0, right = 0 } = padding;
140
+
141
+ const horizontalTotal = left + right;
142
+ if (horizontalTotal >= width) {
143
+ const scale = (width * CLAMP_SCALE_FACTOR) / horizontalTotal;
144
+ left *= scale;
145
+ right *= scale;
146
+ }
147
+
148
+ const verticalTotal = top + bottom;
149
+ if (verticalTotal >= height) {
150
+ const scale = (height * CLAMP_SCALE_FACTOR) / verticalTotal;
151
+ top *= scale;
152
+ bottom *= scale;
153
+ }
154
+
155
+ return { top, bottom, left, right };
156
+ };
157
+
158
+ /** Expand degenerate time domains so Victory can draw valid line paths. */
159
+ export const getTimeseriesXDomain = chartData => {
160
+ if (!chartData?.[0]?.data?.length) return undefined;
161
+
162
+ const times = chartData[0].data.map(point =>
163
+ point.x instanceof Date ? point.x.getTime() : new Date(point.x).getTime()
164
+ );
165
+ const min = Math.min(...times);
166
+ const max = Math.max(...times);
167
+
168
+ if (min === max) {
169
+ const offset =
170
+ HOURS_PER_HALF_DAY *
171
+ MINUTES_PER_HOUR *
172
+ SECONDS_PER_MINUTE *
173
+ MS_PER_SECOND;
174
+ return [new Date(min - offset), new Date(max + offset)];
175
+ }
176
+
177
+ return undefined;
178
+ };
179
+
180
+ /** Legend swatches must use backend series colors, not the default PF theme scale. */
181
+ export const buildLineChartLegendData = (chartData, hiddenSeries) =>
182
+ chartData.map(series => {
183
+ const hidden = hiddenSeries.has(series.name);
184
+
185
+ if (hidden) {
186
+ return {
187
+ childName: series.name,
188
+ name: series.name,
189
+ symbol: {
190
+ type: 'eyeSlash',
191
+ fill: chartColorBlack500.var,
192
+ },
193
+ };
194
+ }
195
+
196
+ return {
197
+ childName: series.name,
198
+ name: series.name,
199
+ ...(series.color && {
200
+ symbol: {
201
+ type: 'square',
202
+ fill: series.color,
203
+ },
204
+ }),
205
+ };
206
+ });