diff --git a/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/AreaChart.test.js b/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/AreaChart.test.js
new file mode 100644
index 00000000000..88215a4a967
--- /dev/null
+++ b/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/AreaChart.test.js
@@ -0,0 +1,106 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import AreaChart from './';
+import { areaChartData } from './AreaChart.fixtures';
+
+jest.unmock('./');
+
+describe('AreaChart', () => {
+ it('renders chart with valid data', () => {
+ const { container } = render(
+
+ );
+
+ // Check for chart element by aria attributes (accessible name is the yAxisLabel)
+ expect(screen.getByRole('img', { name: areaChartData.yAxisLabel })).toBeInTheDocument();
+
+ // Verify chart container is present
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('renders empty state when no data provided', () => {
+ render();
+
+ // Should show the no data message
+ expect(screen.getByText('No data available')).toBeInTheDocument();
+
+ // Should not render chart
+ expect(screen.queryByRole('img', { name: /area chart/i })).not.toBeInTheDocument();
+ });
+
+ it('renders empty state when empty data array provided', () => {
+ render();
+
+ // Should show the no data message
+ expect(screen.getByText('No data available')).toBeInTheDocument();
+
+ // Should not render chart
+ expect(screen.queryByRole('img', { name: /area chart/i })).not.toBeInTheDocument();
+ });
+
+ it('displays custom noDataMsg', () => {
+ const customMsg = 'Custom no data message';
+ render();
+
+ // Should display the custom message
+ expect(screen.getByText(customMsg)).toBeInTheDocument();
+ });
+
+ it('applies custom size to chart', () => {
+ const customSize = { width: 500, height: 300 };
+ const { container } = render(
+
+ );
+
+ // Check the chart wrapper div has correct styles
+ const chartWrapper = container.querySelector('div[style*="height"]');
+ expect(chartWrapper).toHaveStyle({ height: '300px', width: '500px' });
+ });
+
+ it('renders with onclick callback', () => {
+ const onClickMock = jest.fn();
+ const { container } = render(
+
+ );
+
+ // Chart should render when onclick is provided
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('renders all data series', () => {
+ const { container } = render(
+
+ );
+
+ // Verify the chart renders (contains SVG) - accessible name is the yAxisLabel
+ expect(screen.getByRole('img', { name: areaChartData.yAxisLabel })).toBeInTheDocument();
+
+ // The chart should be rendered with the data
+ expect(container.querySelector('svg')).toBeInTheDocument();
+ });
+
+ it('handles missing xAxisDataLabel gracefully', () => {
+ const dataWithoutTime = [
+ ['nottime', 1614449768, 1614451500],
+ ['CentOS 7.9', 3, 5],
+ ];
+
+ render(
+
+ );
+
+ // Should render empty state when time column is not found
+ expect(screen.getByText('No data available')).toBeInTheDocument();
+ });
+});
+
diff --git a/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/index.js b/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/index.js
index 1dd6aa7af68..b00f9d0ce96 100644
--- a/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/index.js
+++ b/webpack/assets/javascripts/react_app/components/common/charts/AreaChart/index.js
@@ -1,7 +1,15 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
-import { AreaChart as PfAreaChart } from 'patternfly-react';
-import { getAreaChartConfig } from '../../../../../services/charts/AreaChartService';
+import {
+ Chart,
+ ChartArea,
+ ChartAxis,
+ ChartGroup,
+ ChartStack,
+ ChartThemeColor,
+ ChartVoronoiContainer,
+ ChartLegend,
+} from '@patternfly/react-charts';
import { noop } from '../../../../common/helpers';
import { translate as __ } from '../../../../common/I18n';
import MessageBox from '../../MessageBox';
@@ -11,35 +19,187 @@ const AreaChart = ({
onclick,
noDataMsg,
config,
- unloadData,
+ unloadData, // eslint-disable-line no-unused-vars
xAxisDataLabel,
yAxisLabel,
size,
}) => {
- const chartConfig = getAreaChartConfig({
- data,
- config,
- onclick,
- yAxisLabel,
- xAxisDataLabel,
- size,
- });
-
- if (chartConfig.data.columns.length) {
- return ;
+ const chartData = useMemo(() => {
+ if (!data || data.length === 0) {
+ return null;
+ }
+
+ // Extract timestamps from first array
+ const timeColumn = data.find(col => col[0] === xAxisDataLabel);
+ if (!timeColumn) {
+ return null;
+ }
+
+ const timestamps = timeColumn.slice(1);
+ const dates = timestamps.map(epochSecs => new Date(epochSecs * 1000));
+
+ // Process other data columns
+ const series = data
+ .filter(col => col[0] !== xAxisDataLabel)
+ .map(col => {
+ const name = col[0];
+ const values = col.slice(1);
+ return {
+ name,
+ data: values.map((value, index) => ({
+ x: dates[index],
+ y: value,
+ name,
+ })),
+ };
+ });
+
+ return series.length > 0 ? series : null;
+ }, [data, xAxisDataLabel]);
+
+ const handleClick = useMemo(
+ () => (event, datum) => {
+ if (onclick && datum && datum.name) {
+ onclick({ id: datum.name });
+ }
+ },
+ [onclick]
+ );
+
+ if (!chartData) {
+ return ;
}
- return ;
+
+ const chartHeight = size?.height || 250;
+ const chartWidth = size?.width || undefined;
+ const padding = { bottom: 75, left: 75, right: 50, top: 50 };
+
+ const legendData = chartData.map(series => ({ name: series.name }));
+
+ return (
+
+ {
+ const value = datum.y0 !== undefined ? datum.y - datum.y0 : datum.y;
+ const seriesName = datum.childName || datum.name;
+
+ // Show timestamp for the first series
+ const firstSeriesName = chartData[0]?.name;
+ const isFirstSeries = seriesName === firstSeriesName;
+
+ let label = '';
+
+ // Add timestamp as first line for first series only
+ if (isFirstSeries && datum.x) {
+ const date = datum.x instanceof Date ? datum.x : new Date(datum.x);
+ const dateStr = date.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' });
+ const timeStr = date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
+ label = `${dateStr}, ${timeStr}\n`;
+ }
+
+ // Add series value if non-zero
+ if (value !== 0) {
+ const valueLine = seriesName ? `${seriesName}: ${Math.round(value)}` : `${Math.round(value)}`;
+ label += valueLine;
+ } else if (isFirstSeries) {
+ // Show first series even if zero to ensure timestamp displays
+ const valueLine = seriesName ? `${seriesName}: ${Math.round(value)}` : `${Math.round(value)}`;
+ label += valueLine;
+ }
+
+ return label || null;
+ }}
+ constrainToVisibleArea
+ />
+ }
+ height={chartHeight}
+ width={chartWidth}
+ padding={padding}
+ themeColor={ChartThemeColor.orange}
+ legendData={legendData}
+ legendOrientation="horizontal"
+ legendPosition="bottom"
+ legendComponent={}
+ events={[
+ {
+ target: 'data',
+ eventHandlers: {
+ onClick: () => [
+ {
+ target: 'data',
+ mutation: props => {
+ handleClick(null, props.datum);
+ return null;
+ },
+ },
+ ],
+ },
+ },
+ ]}
+ >
+ {
+ if (config === 'timeseries' && t instanceof Date) {
+ return t.toLocaleDateString(undefined, {
+ month: 'short',
+ day: 'numeric',
+ });
+ }
+ return t;
+ }}
+ fixLabelOverlap
+ />
+ Math.round(t)}
+ />
+ {config === 'timeseries' ? (
+
+ {chartData.map(series => (
+
+ ))}
+
+ ) : (
+
+ {chartData.map(series => (
+
+ ))}
+
+ )}
+
+
+ );
};
AreaChart.propTypes = {
+ /** Array of data arrays. First element of each array is the label, rest are values.
+ * For timeseries, one array should have xAxisDataLabel as first element and timestamps as subsequent values.
+ * Example: [['time', 1614449768, 1614451500], ['CentOS 7.9', 3, 5], ['CentOS 7.6', 2, 1]]
+ */
data: PropTypes.arrayOf(PropTypes.array),
+ /** Function called when clicking on data points. Receives object with 'id' property containing the series name. */
onclick: PropTypes.func,
+ /** Message to display when no data is available */
noDataMsg: PropTypes.string,
+ /** Chart configuration type. Only 'timeseries' is currently supported. */
config: PropTypes.oneOf(['timeseries']),
+ /** Legacy prop for compatibility - no longer used in PatternFly 5 */
unloadData: PropTypes.bool,
+ /** Label for the x-axis data column (must match first element of one data array) */
xAxisDataLabel: PropTypes.string,
+ /** Label for the y-axis */
yAxisLabel: PropTypes.string,
- size: PropTypes.object,
+ /** Optional size object with width and/or height properties */
+ size: PropTypes.shape({
+ height: PropTypes.number,
+ width: PropTypes.number,
+ }),
};
AreaChart.defaultProps = {