diff --git a/package-lock.json b/package-lock.json index 2a7bd2a8ac..e6276c6a90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "react-virtualized": "9.22.3", "react-virtualized-auto-sizer": "^1.0.24", "react-window": "^1.8.6", + "recharts": "^2.15.1", "styled-components": "^5.2.1", "styled-system": "^5.1.5", "vega": "^5.17.3", @@ -5186,6 +5187,60 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -8594,6 +8649,14 @@ "node": ">=0.10.0" } }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-force": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", @@ -8859,6 +8922,11 @@ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", "dev": true }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "node_modules/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -10853,6 +10921,11 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -11272,6 +11345,14 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -18967,6 +19048,20 @@ "react": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-table": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz", @@ -19214,6 +19309,49 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", + "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, "node_modules/rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -21589,8 +21727,7 @@ "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "dev": true + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, "node_modules/title-case": { "version": "2.1.1", @@ -23135,6 +23272,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index a78f212469..4685de5dbf 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "react-virtualized": "9.22.3", "react-virtualized-auto-sizer": "^1.0.24", "react-window": "^1.8.6", + "recharts": "^2.15.1", "styled-components": "^5.2.1", "styled-system": "^5.1.5", "vega": "^5.17.3", diff --git a/src/lib/components/date/FormattedDateTime.spec.tsx b/src/lib/components/date/FormattedDateTime.spec.tsx index 2f08380425..cb86edb5fe 100644 --- a/src/lib/components/date/FormattedDateTime.spec.tsx +++ b/src/lib/components/date/FormattedDateTime.spec.tsx @@ -214,4 +214,28 @@ describe('FormatttedDateTime', () => { //V expect(screen.getByText('2022-12-12 11:57:26')).toBeInTheDocument(); }); + + it('should display the date in the expected format of the xaxis tick in the chart', () => { + //S + render( + , + ); + //V + expect(screen.getByText('6 Oct 18:33')).toBeInTheDocument(); + }); + + it('should display the date in the expected format of date in the chart', () => { + //S + render( + , + ); + //V + expect(screen.getByText('6 Oct 18:33:00')).toBeInTheDocument(); + }); }); diff --git a/src/lib/components/date/FormattedDateTime.tsx b/src/lib/components/date/FormattedDateTime.tsx index 4d877a5941..283eacd0b8 100644 --- a/src/lib/components/date/FormattedDateTime.tsx +++ b/src/lib/components/date/FormattedDateTime.tsx @@ -21,6 +21,26 @@ export const TIME_FORMATER = Intl.DateTimeFormat('en-GB', { minute: '2-digit', }); +export const DAY_MONTH_ABBREVIATED_HOUR_MINUTE_SECOND = Intl.DateTimeFormat( + 'en-GB', + { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }, +); + +export const DAY_MONTH_ABBREVIATED_HOUR_MINUTE = Intl.DateTimeFormat('en-GB', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit', + hour12: false, +}); + type FormattedDateTimeProps = { format: | 'date' @@ -28,7 +48,9 @@ type FormattedDateTimeProps = { | 'date-time-second' | 'time' | 'time-second' - | 'relative'; + | 'relative' + | 'day-month-abbreviated-hour-minute' + | 'day-month-abbreviated-hour-minute-second'; value: Date; }; @@ -143,7 +165,19 @@ export const FormattedDateTime = ({ few seconds ago ); - //TO FINISH + case 'day-month-abbreviated-hour-minute': + return ( + <>{DAY_MONTH_ABBREVIATED_HOUR_MINUTE.format(value).replace(',', '')} + ); + case 'day-month-abbreviated-hour-minute-second': + return ( + <> + {DAY_MONTH_ABBREVIATED_HOUR_MINUTE_SECOND.format(value).replace( + ',', + '', + )} + + ); default: return <>; } diff --git a/src/lib/components/linetimeseriechart/linetimeseriechart.component.tsx b/src/lib/components/linetimeseriechart/linetimeseriechart.component.tsx new file mode 100644 index 0000000000..13b2a6f0cf --- /dev/null +++ b/src/lib/components/linetimeseriechart/linetimeseriechart.component.tsx @@ -0,0 +1,505 @@ +import { + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, + CartesianGrid, +} from 'recharts'; +import { useMemo, useRef } from 'react'; +import { useTheme } from 'styled-components'; +import { useMetricsTimeSpan } from '../linetemporalchart/MetricTimespanProvider'; +import { addMissingDataPoint } from '../linetemporalchart/ChartUtil'; +import styled from 'styled-components'; +import { + fontSize, + fontWeight, + lineTimeSeriesColorRange, +} from '../../style/theme'; +import { ChartTitleText, SmallerText } from '../text/Text.component'; +import { Loader } from '../loader/Loader.component'; +import { spacing } from '../../spacing'; +import { getUnitLabel } from '../linetemporalchart/ChartUtil'; +import { Icon } from '../icon/Icon.component'; +import { Tooltip as TooltipComponent } from '../tooltip/Tooltip.component'; +import { + DAY_MONTH_ABBREVIATED_HOUR_MINUTE, + FormattedDateTime, +} from '../date/FormattedDateTime'; + +const LineTemporalChartWrapper = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + flex: 1; +`; + +const ChartHeader = styled.div` + display: flex; + align-items: center; +`; + +const TooltipContainer = styled.div` + background-color: ${(props) => props.theme.backgroundLevel1}; + padding: ${spacing.r8}; + border: 1px solid ${(props) => props.theme.border}; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + max-width: 250px; +`; + +const TooltipTime = styled.div` + margin-bottom: ${spacing.r8}; + color: ${(props) => props.theme.textPrimary}; + font-size: ${fontSize.smaller}; + font-weight: ${fontWeight.bold}; + text-align: center; +`; + +const TooltipValue = styled.div` + font-size: ${fontSize.smaller}; + margin-top: 4px; + color: ${(props) => props.theme.textSecondary}; + display: flex; + align-items: flex-start; +`; + +const TooltipLegend = styled.div<{ color: string }>` + width: 12px; + height: 3px; + background-color: ${(props) => props.color}; + margin-right: 8px; + flex-shrink: 0; + margin-top: 8px; +`; + +const TooltipContent = styled.div` + display: flex; + min-width: 0; + flex: 1; +`; + +const TooltipName = styled.div` + margin-right: 4px; + word-wrap: break-word; + word-break: break-word; + justify-content: flex-start; +`; + +const TooltipInstanceValue = styled.div` + justify-content: flex-end; +`; + +export type Serie = { + // the name of the resource + resource: string; + // the original data format from prometheus + data: [number, string | null][]; + // it's mandatory to display tooltip label in the tooltip + getTooltipLabel: (metricPrefix?: string, resource?: string) => string; + // get the legend label for each of the series + getLegendLabel?: (metricPrefix?: string, resource?: string) => string; + // optional color field to specify the color of the line + color?: string; + // the name of the metric prefix with read, write, in, out + metricPrefix?: string; + // to specify if the line is dash + isLineDashed?: boolean; +}; + +type NonSymmetricalChartSerie = { + yAxisType?: 'default' | 'percentage'; + series: Serie[]; +}; + +// The symmetrical chart props are used to display two series on the same chart, such as in/out, write/read +type SymmetricalChartSerie = { + yAxisType: 'symmetrical'; + series: { + above: Serie[]; + below: Serie[]; + }; +}; + +export type LineChartProps = ( + | NonSymmetricalChartSerie + | SymmetricalChartSerie +) & { + title: string; + height: number; + startingTimeStamp: number; + unitRange?: { + threshold: number; + label: string; + }[]; + isLoading?: boolean; + yAxisTitle?: string; + helpText?: string; +}; + +const CustomTooltip = ({ + active, + payload, + label, + unitLabel, +}: { + active?: boolean; + payload?: Array<{ + value: number; + name: string; + color: string; + dataKey: string; + }>; + label?: string; + unitLabel?: string; +}) => { + if (!active || !payload || !payload.length || !label) return null; + // We can't use the default itemSorter method because it's a custom tooltip. + // Sort the payload here instead + const sortedPayload = [...payload].sort((a, b) => { + const aValue = Number(a.value); + const bValue = Number(b.value); + + if (aValue >= 0 && bValue >= 0) { + return bValue - aValue; // Higher positive values first + } + if (aValue < 0 && bValue < 0) { + return bValue - aValue; // Lower negative values first + } + return bValue - aValue; // Positives before negatives + }); + + return ( + + + + + {sortedPayload.map((entry, index) => ( + + + + {entry.name} + + {isNaN(Number(entry.value)) + ? '-' + : `${Number(entry.value).toFixed(2)}${unitLabel}`} + + + + ))} + + ); +}; + +const isSymmetricalSeries = ( + series: Serie[] | { above: Serie[]; below: Serie[] }, +): series is { above: Serie[]; below: Serie[] } => { + return 'above' in series && 'below' in series; +}; + +export function LineTimeSerieChart({ + series, + title, + height, + startingTimeStamp, + unitRange, + isLoading = false, + yAxisType = 'default', + yAxisTitle, + helpText, + ...rest +}: LineChartProps) { + const theme = useTheme(); + const { frequency, duration } = useMetricsTimeSpan(); + const chartRef = useRef(null); + + const chartData = useMemo(() => { + // 1. Add missing data points + const normalizedSeries = + yAxisType === 'symmetrical' && isSymmetricalSeries(series) + ? { + above: series.above.map((line) => ({ + ...line, + data: addMissingDataPoint( + line.data, + startingTimeStamp, + duration, + frequency, + ), + })), + // Convert positive values to negative values + below: series.below.map((line) => ({ + ...line, + data: addMissingDataPoint( + line.data, + startingTimeStamp, + duration, + frequency, + ).map( + ([timestamp, value]) => + [timestamp, value === null ? null : `-${Number(value)}`] as [ + number, + string | null, + ], + ), + })), + } + : (series as Serie[]).map((line) => ({ + ...line, + data: addMissingDataPoint( + line.data, + startingTimeStamp, + duration, + frequency, + ), + })); + + // 2. Convert directly to Recharts format + // Initialize an object to hold data points by timestamp + const dataPointsByTime: Record< + number, + { timestamp: number } & Record + > = {}; + const seriesToProcess = + yAxisType === 'symmetrical' && isSymmetricalSeries(normalizedSeries) + ? [...normalizedSeries.above, ...normalizedSeries.below] + : (normalizedSeries as Serie[]); + + seriesToProcess.forEach((serie) => { + const label = serie.getTooltipLabel(serie.metricPrefix, serie.resource); + + serie.data.forEach((point) => { + const timestamp = + typeof point[0] === 'number' ? point[0] * 1000 : Number(point[0]); + const value = point[1]; + // Initialize this timestamp if it doesn't exist + if (!dataPointsByTime[timestamp]) { + dataPointsByTime[timestamp] = { timestamp }; + } + // Add this metric's value to the data point, and convert the value to a number if it's a string + dataPointsByTime[timestamp][label] = + typeof value === 'string' ? Number(value) : value; + }); + }); + // Convert object to array for Recharts + return Object.values(dataPointsByTime).sort( + ( + a: { timestamp: number } & Record, + b: { timestamp: number } & Record, + ) => (a.timestamp as number) - (b.timestamp as number), + ); + }, [series, startingTimeStamp, duration, frequency, yAxisType]); + + // Calculate 5 perfectly evenly spaced ticks + const xAxisTicks = useMemo(() => { + if (!chartData || chartData.length === 0) return []; + + const timestamps: number[] = chartData.map((d) => d.timestamp); + const minTimestamp = Math.min(...timestamps); + const maxTimestamp = Math.max(...timestamps); + + // Calculate 5 perfectly evenly spaced ticks + const timeRange = maxTimestamp - minTimestamp; + const interval = timeRange / 4; // 4 intervals create 5 points + + const exactEvenTicks = [ + minTimestamp, + minTimestamp + interval, + minTimestamp + interval * 2, + minTimestamp + interval * 3, + maxTimestamp, + ]; + + // Return perfectly even ticks (guaranteed to be evenly divided) + return exactEvenTicks; + }, [chartData]); + + // 3. Transform the data base on the valuebase + const { topValue, unitLabel, rechartsData } = useMemo(() => { + if (yAxisType === 'percentage') + return { + topValue: 100, + unitLabel: '%', + rechartsData: chartData, + }; + + const values = chartData.flatMap((dataPoint) => + Object.entries(dataPoint) + .filter(([key]) => key !== 'timestamp') + .map(([_, value]) => { + const num = + typeof value === 'string' ? Number(value) : (value ?? Infinity); + return !isNaN(num) && num !== null ? num : null; + }) + .filter((value): value is number => value !== null), + ); + + const top = Math.abs(Math.max(...values)); + const bottom = Math.abs(Math.min(...values)); + const maxValue = Math.max(top, bottom); + + const { valueBase, unitLabel } = getUnitLabel(unitRange ?? [], maxValue); + + const topValue = Math.ceil(maxValue / valueBase / 10) * 10; + + const rechartsData = chartData.map((dataPoint) => { + const normalizedDataPoint = { ...dataPoint }; + Object.entries(dataPoint).forEach(([key, value]) => { + if (key !== 'timestamp' && typeof value === 'number') { + normalizedDataPoint[key] = value / valueBase; + } + }); + return normalizedDataPoint; + }); + + return { topValue, unitLabel, rechartsData }; + }, [chartData, yAxisType, unitRange]); + + // Group series by resource and create color mapping + const { colorMapping, groupedSeries } = useMemo(() => { + const mapping: Record = {}; + const allSeries = isSymmetricalSeries(series) + ? [...series.above, ...series.below] + : (series as Serie[]); + + // Group series by resource + const groups = allSeries.reduce( + (acc, serie) => { + const key = serie.resource; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(serie); + return acc; + }, + {} as Record, + ); + + // Todo: The color will be assigned through the context. + Object.keys(groups).forEach((resource, index) => { + const color = + lineTimeSeriesColorRange[index % lineTimeSeriesColorRange.length]; + mapping[resource] = color; + }); + + return { + colorMapping: mapping, + groupedSeries: groups, + }; + }, [series]); + + // Format time for display the tick in the x axis + const formatTime = useMemo( + () => (timestamp: number) => { + const date = new Date(timestamp); + return DAY_MONTH_ABBREVIATED_HOUR_MINUTE.format(date).replace(',', ''); + }, + [], + ); + + return ( + + + + {title} {unitLabel && `(${unitLabel})`} + + {helpText && ( + {helpText}} + > + + + )} + {isLoading && } + + + + + + Math.round(value).toString()} + /> + } /> + {/* Add horizontal line at y=0 for symmetrical charts */} + {yAxisType === 'symmetrical' && ( + + )} + + {/* Chart lines */} + {Object.entries(groupedSeries).map(([resource, resourceSeries]) => + resourceSeries.map((serie, serieIndex) => { + const label = serie.getTooltipLabel( + serie.metricPrefix, + serie.resource, + ); + return ( + + ); + }), + )} + + + + ); +} diff --git a/src/lib/index.ts b/src/lib/index.ts index d4c73ff939..662131580d 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -82,3 +82,4 @@ export { InfoMessage } from './components/infomessage/InfoMessage.component'; export { InputList } from './components/inputlist/InputList.component'; export { InlineInput } from './components/inlineinput/InlineInput'; export { UnsuccessfulResult } from './components/UnsuccessfulResult.component'; +export { LineTimeSerieChart } from './components/linetimeseriechart/linetimeseriechart.component'; diff --git a/src/lib/style/theme.ts b/src/lib/style/theme.ts index 7d5b14e01d..e83c3e478d 100644 --- a/src/lib/style/theme.ts +++ b/src/lib/style/theme.ts @@ -1,3 +1,4 @@ +import { lighten, darken } from 'polished'; //== Colors export const hotPink = '#E40046'; export const pink = '#EB4962'; @@ -268,3 +269,31 @@ export const navbarItemWidth = '4.286rem'; //sidebar export const sidebarItemHeight = spacing.sp40; export const sidebarWidth = spacing.sp40; + +// We use 8 main color from the palette and decline them (lighter/ darker) when we have more than 8 datasets +export const lineTimeSeriesColorRange = [ + lineColor1, + lineColor2, + lineColor3, + lineColor4, + lineColor5, + lineColor6, + lineColor7, + lineColor8, + lighten(0.3, lineColor1), + lighten(0.3, lineColor2), + lighten(0.3, lineColor3), + lighten(0.3, lineColor4), + lighten(0.3, lineColor5), + lighten(0.3, lineColor6), + lighten(0.3, lineColor7), + lighten(0.3, lineColor8), + darken(0.2, lineColor1), + darken(0.2, lineColor2), + darken(0.2, lineColor3), + darken(0.2, lineColor4), + darken(0.3, lineColor5), + darken(0.3, lineColor6), + darken(0.3, lineColor7), + darken(0.3, lineColor8), +]; diff --git a/stories/format.mdx b/stories/format.mdx index 50b9905ca0..ed7b10b9cc 100644 --- a/stories/format.mdx +++ b/stories/format.mdx @@ -32,8 +32,10 @@ import { Meta } from '@storybook/blocks'; | Short#1 | DD MMM | 20 Jul | 5 | Chart time axis | | Short#2 | DDMMM HH:mm | 20Jul 09:00 | 11 | Limited space, year not needed | | Short#3 | YYYY-MM-DD | 2020-07-20 | 10 | Tables | -| Mid#1 | YYYY-MM-DD HH:mm | 2020-07-20 09:00 | 16 | Tables (creation/modification dates) | -| Mid#2 | YYYY-MM-DD HH:mm:ss | 2020-07-20 09:00:00 | 19 | When the seconds are needed | +| Mid#1 | DD MMM HH:mm | 20 Jul 09:00 | 12 | Chart Axis ticks | +| Mid#2 | DD MMM HH:mm:ss | 20 Jul 09:00:00 | 15 | Chart Tooltip title | +| Mid#3 | YYYY-MM-DD HH:mm | 2020-07-20 09:00 | 16 | Tables (creation/modification dates) | +| Mid#4 | YYYY-MM-DD HH:mm:ss | 2020-07-20 09:00:00 | 19 | When the seconds are needed | | Full#1 | EEE MMM DD YYYY HH:mm:ss | Mon Jul 20 2020 09:00:00 | 24 | When a lot of space (hover) - When precision is needed | ### Remarks: diff --git a/stories/linetimeseriechart.stories.tsx b/stories/linetimeseriechart.stories.tsx new file mode 100644 index 0000000000..ab48ae04e2 --- /dev/null +++ b/stories/linetimeseriechart.stories.tsx @@ -0,0 +1,462 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { MetricsTimeSpanProvider } from '../src/lib/components/linetemporalchart/MetricTimespanProvider'; +import { LineTimeSerieChart } from '../src/lib/components/linetimeseriechart/linetimeseriechart.component'; + +const ChartWithProviders = (props) => { + return ( + + + + ); +}; + +const meta: Meta = { + title: 'Components/Data Display/Charts/LineTimeSerieChart', + component: ChartWithProviders, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + argTypes: { + series: { control: 'object' }, + title: { control: 'text' }, + height: { control: 'number' }, + startingTimeStamp: { control: 'number' }, + unitRange: { control: 'object' }, + isLoading: { control: 'boolean' }, + yAxisType: { + control: 'select', + options: ['default', 'percentage', 'symmetrical'], + }, + yAxisTitle: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +const prometheusData = [ + [1740405600, '47.554166666666674'], + [1740406320, '53.00833333333337'], + [1740407760, '40.18750000000011'], + [1740408480, '59.187499999999886'], + [1740409200, '62.49583333333344'], + [1740409920, '57.449999999999854'], + [1740410640, '45.187500000000114'], + [1740411360, '58.620833333332975'], + [1740412080, '53.0786411974533'], + [1740412800, '64.90833333333342'], + [1740413520, '81.23333333333335'], + [1740414240, '64.81614215228686'], + [1740415680, '67.17291666666674'], + [1740416400, '55.233333333333405'], + [1740417120, '47.91666666666667'], + [1740417840, '72.1083333333335'], + [1740418560, '61.90904847636171'], + [1740419280, '51.06666666666646'], + [1740420000, '39.50416666666664'], + [1740420720, '46.91666666666682'], + [1740421440, '35.0541666666669'], + [1740422160, '46.666666666666856'], + [1740422880, '43.57083333333397'], + [1740423600, '36.795833333333114'], + [1740467520, '42.81249999999962'], + [1740468240, '53.44272951530158'], + [1740468960, '55.69583333333337'], + [1740469680, '46.09583333333338'], + [1740470400, '43.020833333333705'], + [1740471120, '55.395833333333634'], + [1740471840, '46.56249999999999'], + [1740472560, '47.16249999999983'], + [1740473280, '44.69166666666676'], + [1740474000, '55.29583333333411'], + [1740474720, '42.25833333333412'], + [1740475440, '61.570833333332764'], + [1740476160, '42.86666666666633'], + [1740476880, '38.03750000000036'], + [1740477600, '41.32916666666612'], + [1740478320, '52.22499999999779'], + [1740479040, '42.13333333333368'], + [1740479760, '34.791666666665904'], + [1740480480, '36.58333333333227'], + [1740481200, '37.20833333333226'], + [1740481920, '49.19583333333246'], + [1740482640, '41.016666666666126'], + [1740483360, '37.54166666666834'], + [1740484080, '35.38750000000013'], + [1740484800, '39.02500000000069'], + [1740485520, '47.14054738807047'], + [1740486240, '46.79166666666787'], + [1740486960, '42.950363357557244'], + [1740487680, '35.22916666666637'], + [1740488400, '34.94583333333519'], + [1740489120, '41.044577067983646'], + [1740489840, '58.17083333333282'], + [1740490560, '40.00000000000304'], + [1740491280, '45.57500000000194'], + [1740492000, '45.741666666666184'], +]; + +const prometheusData2 = [ + [1740405600, '45.23'], + [1740406320, '120.45'], + [1740407760, '35.88'], + [1740408480, '42.98'], + [1740409200, '88.29'], + [1740409920, '25.84'], + [1740410640, '95.38'], + [1740411360, '30.22'], + [1740412080, '78.17'], + [1740412800, '110.40'], + [1740413520, '45.83'], + [1740414240, '92.21'], + [1740415680, '28.57'], + [1740416400, '75.43'], + [1740417120, '115.31'], + [1740417840, '38.50'], + [1740418560, '82.40'], + [1740419280, '27.86'], + [1740420000, '98.90'], + [1740420720, '31.21'], + [1740421440, '85.25'], + [1740422160, '105.86'], + [1740422880, '42.97'], + [1740423600, '71.19'], + [1740467520, '95.41'], + [1740468240, '36.84'], + [1740468960, '88.89'], + [1740469680, '29.39'], + [1740470400, '96.42'], + [1740471120, '33.79'], + [1740471840, '108.96'], + [1740472560, '45.56'], + [1740473280, '77.89'], + [1740474000, '102.49'], + [1740474720, '35.65'], + [1740475440, '88.97'], + [1740476160, '25.26'], + [1740476880, '91.43'], + [1740477600, '104.72'], + [1740478320, '39.62'], + [1740479040, '85.53'], + [1740479760, '28.19'], + [1740480480, '99.98'], + [1740481200, '41.60'], + [1740481920, '76.59'], + [1740482640, '113.41'], + [1740483360, '30.94'], + [1740484080, '88.78'], + [1740484800, '32.42'], + [1740485520, '95.54'], + [1740486240, '29.19'], + [1740486960, '86.35'], + [1740487680, '118.62'], + [1740488400, '37.34'], + [1740489120, '84.44'], + [1740489840, '34.57'], + [1740490560, '93.40'], + [1740491280, '28.97'], + [1740492000, '107.14'], +]; + +const prometheusData3 = [ + [1740405600, '65.32'], + [1740406320, '145.78'], + [1740407760, '42.91'], + [1740408480, '88.45'], + [1740409200, '132.67'], + [1740409920, '35.89'], + [1740410640, '128.45'], + [1740411360, '48.23'], + [1740412080, '95.34'], + [1740412800, '155.67'], + [1740413520, '52.45'], + [1740414240, '115.89'], + [1740415680, '38.92'], + [1740416400, '92.45'], + [1740417120, '142.34'], + [1740417840, '45.67'], + [1740418560, '108.90'], + [1740419280, '32.45'], + [1740420000, '125.67'], + [1740420720, '41.23'], + [1740421440, '98.45'], + [1740422160, '138.90'], + [1740422880, '55.67'], + [1740423600, '85.34'], + [1740467520, '122.45'], + [1740468240, '44.56'], + [1740468960, '105.78'], + [1740469680, '36.89'], + [1740470400, '118.90'], + [1740471120, '48.23'], + [1740471840, '148.67'], + [1740472560, '58.90'], + [1740473280, '92.45'], + [1740474000, '135.67'], + [1740474720, '42.34'], + [1740475440, '108.90'], + [1740476160, '38.45'], + [1740476880, '115.67'], + [1740477600, '142.34'], + [1740478320, '52.45'], + [1740479040, '102.34'], + [1740479760, '35.67'], + [1740480480, '128.90'], + [1740481200, '45.67'], + [1740481920, '95.34'], + [1740482640, '152.45'], + [1740483360, '42.34'], + [1740484080, '112.45'], + [1740484800, '38.90'], + [1740485520, '122.34'], + [1740486240, '35.67'], + [1740486960, '105.78'], + [1740487680, '158.90'], + [1740488400, '48.23'], + [1740489120, '98.45'], + [1740489840, '42.34'], + [1740490560, '118.90'], + [1740491280, '35.67'], + [1740492000, '145.78'], +]; + +const prometheusData4 = [ + [1740405600, '85.45'], + [1740406320, '178.92'], + [1740407760, '32.15'], + [1740408480, '142.78'], + [1740409200, '195.34'], + [1740409920, '28.67'], + [1740410640, '165.89'], + [1740411360, '45.23'], + [1740412080, '138.56'], + [1740412800, '188.90'], + [1740413520, '35.67'], + [1740414240, '155.45'], + [1740415680, '25.89'], + [1740416400, '128.90'], + [1740417120, '182.34'], + [1740417840, '42.56'], + [1740418560, '145.78'], + [1740419280, '22.34'], + [1740420000, '168.90'], + [1740420720, '38.45'], + [1740421440, '135.67'], + [1740422160, '192.45'], + [1740422880, '48.23'], + [1740423600, '158.90'], + [1740467520, '28.45'], + [1740468240, '175.67'], + [1740468960, '42.34'], + [1740469680, '148.90'], + [1740470400, '185.67'], + [1740471120, '32.45'], + [1740471840, '172.34'], + [1740472560, '45.67'], + [1740473280, '152.89'], + [1740474000, '198.45'], + [1740474720, '25.67'], + [1740475440, '162.34'], + [1740476160, '35.89'], + [1740476880, '145.67'], + [1740477600, '188.90'], + [1740478320, '42.34'], + [1740479040, '158.90'], + [1740479760, '28.45'], + [1740480480, '175.67'], + [1740481200, '38.90'], + [1740481920, '142.34'], + [1740482640, '192.45'], + [1740483360, '32.67'], + [1740484080, '165.89'], + [1740484800, '45.23'], + [1740485520, '155.67'], + [1740486240, '25.89'], + [1740486960, '168.90'], + [1740487680, '195.34'], + [1740488400, '35.67'], + [1740489120, '148.90'], + [1740489840, '42.34'], + [1740490560, '172.45'], + [1740491280, '28.67'], + [1740492000, '185.90'], +]; + +const prometheusData5 = [ + [1740405600, '12850.45'], + [1740406320, '21780.92'], + [1740407760, '8320.15'], + [1740408480, '16420.78'], + [1740409200, '21950.34'], + [1740409920, '9280.67'], + [1740410640, '18605.89'], + [1740411360, '12450.23'], + [1740412080, '21308.56'], + [1740412800, '18808.90'], + [1740413520, '15305.67'], + [1740414240, '24550.45'], + [1740415680, '10205.89'], + [1740416400, '19208.90'], + [1740417120, '24802.34'], + [1740417840, '13402.56'], + [1740418560, '21450.78'], + [1740419280, '18220.34'], + [1740420000, '26608.90'], + [1740420720, '19380.45'], + [1740421440, '23350.67'], + [1740422160, '19920.45'], + [1740422880, '15480.23'], + [1740423600, '28580.90'], + [1740467520, '17280.45'], + [1740468240, '24750.67'], + [1740468960, '18420.34'], + [1740469680, '23480.90'], + [1740470400, '19850.67'], + [1740471120, '27320.45'], + [1740471840, '19720.34'], + [1740472560, '24405.67'], + [1740473280, '18502.89'], + [1740474000, '27908.45'], + [1740474720, '16205.67'], + [1740475440, '23602.34'], + [1740476160, '19305.89'], + [1740476880, '24450.67'], + [1740477600, '18858.90'], + [1740478320, '26492.34'], + [1740479040, '19588.90'], + [1740479760, '28288.45'], + [1740480480, '19765.67'], + [1740481200, '23398.90'], + [1740481920, '19492.34'], + [1740482640, '25929.45'], + [1740483360, '18329.67'], + [1740484080, '24659.89'], + [1740484800, '19459.23'], + [1740485520, '27559.67'], + [1740486240, '18259.89'], + [1740486960, '25689.90'], + [1740487680, '19959.34'], + [1740488400, '28359.67'], + [1740489120, '19489.90'], + [1740489840, '26429.34'], + [1740490560, '19729.45'], + [1740491280, '28289.67'], + [1740492000, '19859.90'], +]; + +export const PercentageChartExample: Story = { + args: { + series: [ + { + data: prometheusData, + resource: 'ip-10-160-122-207.eu-north-1.compute.internal', + metricPrefix: 'instance:10.160.122.207:9100', + getTooltipLabel: (prefix, resource) => `${resource}`, + getLegendLabel: (prefix, resource) => `${resource}`, + }, + ], + title: 'CPU Usage', + height: 200, + startingTimeStamp: prometheusData[0][0], + isLoading: false, + isLegendHidden: false, + helpText: 'This is the help text', + yAxisType: 'percentage', + yAxisTitle: '', + }, +}; +const UNIT_RANGE_BS = [ + { + threshold: 1, + label: 'B/s', + }, + { + threshold: 1024, + label: 'KiB/s', + }, + { + threshold: 1024 * 1024, + label: 'MiB/s', + }, + { + threshold: 1024 * 1024 * 1024, + label: 'GiB/s', + }, + { + threshold: 1024 * 1024 * 1024 * 1024, + label: 'TiB/s', + }, +]; +export const SymmetricalAxisExample: Story = { + args: { + series: { + above: [ + { + data: prometheusData, + resource: 'ip-10-160-122-207.eu-north-1.compute.internal', + metricPrefix: 'in', + getTooltipLabel: (prefix, resource) => `${resource}-${prefix}`, + getLegendLabel: (prefix, resource) => `${resource}}`, + }, + { + data: prometheusData2, + resource: 'ip-10-160-122-207.eu-north-2.compute.internal', + metricPrefix: 'in', + getTooltipLabel: (prefix, resource) => `${resource}-${prefix}`, + getLegendLabel: (prefix, resource) => `${resource}}`, + }, + ], + below: [ + { + data: prometheusData3, + resource: 'ip-10-160-122-207.eu-north-1.compute.internal', + metricPrefix: 'out', + getTooltipLabel: (prefix, resource) => `${resource}-${prefix}`, + getLegendLabel: (prefix, resource) => `${resource}`, + }, + { + data: prometheusData4, + resource: 'ip-10-160-122-207.eu-north-2.compute.internal', + metricPrefix: 'out', + getTooltipLabel: (prefix, resource) => `${resource}-${prefix}`, + getLegendLabel: (prefix, resource) => `${resource}`, + }, + ], + }, + title: 'ControlPlane Bandwidth', + height: 200, + startingTimeStamp: prometheusData[0][0], + unitRange: UNIT_RANGE_BS, + isLoading: false, + isLegendHidden: false, + yAxisType: 'symmetrical', + yAxisTitle: 'in(+)/out(-)', + }, +}; +export const AutoUnitChartExample: Story = { + args: { + series: [ + { + data: prometheusData5, + resource: 'ip-10-160-122-207.eu-north-1.compute.internal', + metricPrefix: 'instance:10.160.122.207:9100', + getTooltipLabel: (prefix, resource) => `${resource}`, + getLegendLabel: (prefix, resource) => `${resource}`, + }, + ], + title: 'Disk Throughput', + startingTimeStamp: prometheusData5[0][0], + height: 200, + unitRange: UNIT_RANGE_BS, + yAxisType: 'default', + }, +};