From fb63e1ea94c1a176078605f77d00bd5a41ea6405 Mon Sep 17 00:00:00 2001 From: vmangalr Date: Mon, 26 Jan 2026 11:56:43 +0530 Subject: [PATCH 1/6] [DI-29167] - Changes for integrating zoom in feature in cloudpulse widget and line graph --- .../src/components/AreaChart/AreaChart.tsx | 58 +++++- packages/manager/src/featureFlags.ts | 5 + .../src/features/CloudPulse/Utils/utils.ts | 2 +- .../CloudPulse/Widget/CloudPulseWidget.tsx | 11 ++ .../Widget/components/CloudPulseLineGraph.tsx | 165 ++++++++++++++---- .../manager/src/queries/cloudpulse/metrics.ts | 3 +- 6 files changed, 204 insertions(+), 40 deletions(-) diff --git a/packages/manager/src/components/AreaChart/AreaChart.tsx b/packages/manager/src/components/AreaChart/AreaChart.tsx index a37fdbb1632..1188255bb24 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.tsx @@ -8,6 +8,7 @@ import { Area, CartesianGrid, Legend, + ReferenceArea, ResponsiveContainer, Tooltip, XAxis, @@ -26,6 +27,7 @@ import { } from './utils'; import type { TooltipProps } from 'recharts'; +import type { CategoricalChartFunc } from 'recharts/types/chart/generateCategoricalChart'; import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; export interface DataSet { @@ -47,6 +49,32 @@ export interface AreaProps { dataKey: string; } +interface ZoomCallbacks { + /** + * Callback fired on mouse down event on the chart + */ + onMouseDown?: CategoricalChartFunc; + /** + * Callback fired on mouse move event on the chart + */ + onMouseMove?: CategoricalChartFunc; + /** + * Callback fired on mouse up event on the chart + */ + onMouseUp?: CategoricalChartFunc; +} + +interface ReferenceAreaProps { + /** + * Ending x-axis value of the reference area + */ + referenceEnd: number; + /** + * Starting x-axis value of the reference area + */ + referenceStart: number; +} + interface XAxisProps { /** * format for the x-axis timestamp @@ -118,6 +146,11 @@ export interface AreaChartProps { */ margin?: { bottom: number; left: number; right: number; top: number }; + /** + * reference area to be highlighted on the chart + */ + referenceArea?: null | ReferenceAreaProps; + /** * control the visibility of dots for each data points */ @@ -171,6 +204,11 @@ export interface AreaChartProps { * y-axis properties */ yAxisProps?: YAxisProps; + + /** + * zoom callbacks (onMouseDown, onMouseMove, onMouseUp) + */ + zoomCallbacks?: ZoomCallbacks; } export const AreaChart = (props: AreaChartProps) => { @@ -195,9 +233,13 @@ export const AreaChart = (props: AreaChartProps) => { xAxisTickCount, yAxisProps, tooltipCustomValueFormatter, + zoomCallbacks, + referenceArea, } = props; const theme = useTheme(); + const { onMouseDown, onMouseMove, onMouseUp } = zoomCallbacks || {}; + const { referenceStart, referenceEnd } = referenceArea || {}; const [activeSeries, setActiveSeries] = React.useState>([]); const handleLegendClick = (dataKey: string) => { @@ -280,7 +322,14 @@ export const AreaChart = (props: AreaChartProps) => { height={height} width={width} > - <_AreaChart aria-label={ariaLabel} data={data} margin={margin}> + <_AreaChart + aria-label={ariaLabel} + data={data} + margin={margin} + onMouseDown={onMouseDown} + onMouseMove={onMouseMove} + onMouseUp={onMouseUp} + > { wrapperStyle={legendStyles} /> )} + {referenceStart !== undefined && referenceEnd !== undefined && ( + + )} {areas.map(({ color, dataKey }) => ( { if (value >= 1000) { return +(value / 1000).toFixed(1) + 'K'; } - return `${roundTo(value, 1)}`; + return `${roundTo(value, 2)}`; }; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index de7a0b3ef16..c8ae74a05d6 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -172,6 +172,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const [groupBy, setGroupBy] = React.useState( props.widget.group_by ); + const [isZoomed, setIsZoomed] = React.useState(false); const theme = useTheme(); const { @@ -401,6 +402,10 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { }, [savePref, updatePreferences, widget.label] ); + + const handleZoomStateChange = React.useCallback((zoomed: boolean) => { + setIsZoomed(zoomed); + }, []); const { data: metricsList, error, @@ -428,6 +433,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { label: widget.label, timeStamp, url: flags.aclpReadEndpoint!, + shouldRefresh: !isZoomed, } ); let data: DataSet[] = []; @@ -585,12 +591,17 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { metricsApiCallError === jweTokenExpiryError || isJweTokenFetching } // keep loading until we are trying to fetch the refresh token + onZoomChange={handleZoomStateChange} showDot showLegend={data.length !== 0} timezone={timezone} unit={`${currentUnit}${unit.endsWith('ps') ? '/s' : ''}`} variant={variant} xAxis={{ tickFormat, tickGap: 60 }} + zoomResetKey={ + props.duration.preset ?? + `${props.duration.start},${props.duration.end},${props.duration.timeZone}` // key to reset zoom when duration changes + } /> diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx index 4fe33e746e9..1004865fc68 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -1,4 +1,4 @@ -import { CircleProgress, ErrorState, Typography } from '@linode/ui'; +import { Button, CircleProgress, ErrorState, Typography } from '@linode/ui'; import { roundTo } from '@linode/utilities'; import { Box, useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; @@ -6,17 +6,38 @@ import * as React from 'react'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; import { useFlags } from 'src/hooks/useFlags'; +import { + computeLegendRowsBasedOnData, + computeZoomedInData, +} from '../../Utils/CloudPulseZoomInUtils'; import { humanizeLargeData } from '../../Utils/utils'; +import { useZoomController } from './useZoomController'; -import type { AreaChartProps } from 'src/components/AreaChart/AreaChart'; +import type { + AreaChartProps, + DataSet, +} from 'src/components/AreaChart/AreaChart'; export interface CloudPulseLineGraph extends AreaChartProps { + data: DataSet[]; error?: string; loading?: boolean; + onZoomChange?: (isZoomed: boolean) => void; + zoomResetKey: string; } export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { - const { error, loading, unit, ...rest } = props; + const { + error, + loading, + unit, + data, + legendRows, + zoomResetKey, + onZoomChange, + showLegend, + ...rest + } = props; const flags = useFlags(); const theme = useTheme(); @@ -24,6 +45,49 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { // to reduce the x-axis tick count for small screen const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const isHumanizableUnit = + flags.aclp?.humanizableUnits?.some( + (unitElement) => unitElement.toLowerCase() === unit.toLowerCase() + ) ?? false; + + const isZoomEnabled = flags.aclp?.enableZoomInCharts ?? false; // default to false + + const { zoom, isZoomed, zoomOut, zoomCallbacks } = + useZoomController(zoomResetKey); + + const zoomedData = React.useMemo(() => { + if (!isZoomEnabled) { + return data; + } + return computeZoomedInData({ data, zoom }); + }, [data, zoom, isZoomEnabled]); + + const zoomedLegendRows = React.useMemo(() => { + if (!isZoomEnabled) { + return legendRows; + } + return computeLegendRowsBasedOnData({ + zoom, + data: zoomedData, + legendRows, + unit: props.unit, + isHumanizableUnit, + }); + }, [ + isHumanizableUnit, + isZoomEnabled, + legendRows, + props.unit, + zoom, + zoomedData, + ]); + + React.useEffect(() => { + if (onZoomChange) { + onZoomChange(isZoomed); + } + }, [isZoomed, onZoomChange]); + if (loading) { return ; } @@ -33,10 +97,6 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { } const noDataMessage = 'No data to display'; - const isHumanizableUnit = - flags.aclp?.humanizableUnits?.some( - (unitElement) => unitElement.toLowerCase() === unit.toLowerCase() - ) ?? false; return ( { ) : ( - `${humanizeLargeData(value)} ${unit}` - : undefined - } - unit={unit} - xAxisTickCount={ - isSmallScreen ? undefined : Math.min(rest.data.length, 7) - } - yAxisProps={ - isHumanizableUnit - ? { - tickFormat: (value: number) => `${humanizeLargeData(value)}`, - } - : { - tickFormat: (value: number) => `${roundTo(value, 3)}`, - } - } - /> + + {isZoomed && ( + + )} + 0 ? showLegend : false} + tooltipCustomValueFormatter={ + isHumanizableUnit + ? (value, unit) => `${humanizeLargeData(value)} ${unit}` + : undefined + } + unit={unit} + xAxisTickCount={ + isSmallScreen ? undefined : Math.min(zoomedData.length, 7) + } + yAxisProps={ + isHumanizableUnit + ? { + tickFormat: (value: number) => + `${humanizeLargeData(value)}`, + } + : { + tickFormat: (value: number) => `${roundTo(value, 3)}`, + } + } + zoomCallbacks={isZoomEnabled ? zoomCallbacks : undefined} + /> + )} - {rest.data.length === 0 && ( + {zoomedData.length === 0 && ( Date: Mon, 26 Jan 2026 12:10:34 +0530 Subject: [PATCH 2/6] [DI-29167] - Changeset --- .../.changeset/pr-13317-upcoming-features-1769409620410.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13317-upcoming-features-1769409620410.md diff --git a/packages/manager/.changeset/pr-13317-upcoming-features-1769409620410.md b/packages/manager/.changeset/pr-13317-upcoming-features-1769409620410.md new file mode 100644 index 00000000000..86c8a7c4b03 --- /dev/null +++ b/packages/manager/.changeset/pr-13317-upcoming-features-1769409620410.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Changes for providing ability to zoom in inside the `CloudPulse Metrics Graphs` ([#13317](https://github.com/linode/manager/pull/13317)) From 14169d56814f7b4afc042604fe7c7ca3ceac8730 Mon Sep 17 00:00:00 2001 From: vmangalr Date: Mon, 26 Jan 2026 12:59:46 +0530 Subject: [PATCH 3/6] [DI-29167] - Typecheck fix --- .../Widget/components/CloudPulseLineGraph.test.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx index 0e5cd7ab381..1957e880c64 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.test.tsx @@ -33,12 +33,14 @@ class ResizeObserver { unobserve() {} } +const zoomResetKey = 'test-zoom'; + describe('CloudPulseLineGraph', () => { window.ResizeObserver = ResizeObserver; it('should render AreaChart when data is provided', () => { const { container, getByRole } = renderWithTheme( - + ); const table = getByRole('table'); @@ -53,7 +55,11 @@ describe('CloudPulseLineGraph', () => { it('should show error state', () => { const { getByText } = renderWithTheme( - + ); expect(getByText('Test error')).toBeInTheDocument(); @@ -66,7 +72,7 @@ describe('CloudPulseLineGraph', () => { }; const { getByText } = renderWithTheme( - + ); expect(getByText('No data to display')).toBeInTheDocument(); From 013fe4eefb4d348adf4a7b87e81de818f3f88e44 Mon Sep 17 00:00:00 2001 From: vmangalr Date: Mon, 26 Jan 2026 13:13:11 +0530 Subject: [PATCH 4/6] [DI-29167] - Zoom key modification --- .../CloudPulse/Widget/CloudPulseWidget.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index c8ae74a05d6..654e63f612d 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -468,6 +468,19 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const hours = end.diff(start, 'hours').hours; const tickFormat = hours <= 24 ? 'hh:mm a' : 'LLL dd'; + const zoomResetKey = React.useMemo(() => { + const { preset, start, end, timeZone } = props.duration; + + if (preset) { + return `preset:${preset}`; + } + if (!start || !end || !timeZone) { + return 'custom:invalid'; + } + + return `custom:${start},${end},${timeZone}`; + }, [props.duration]); + React.useEffect(() => { if ( filteredSelections.length !== (dimensionFilters?.length ?? 0) && @@ -599,8 +612,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { variant={variant} xAxis={{ tickFormat, tickGap: 60 }} zoomResetKey={ - props.duration.preset ?? - `${props.duration.start},${props.duration.end},${props.duration.timeZone}` // key to reset zoom when duration changes + zoomResetKey // key to reset zoom when duration changes } /> From 83ceba85e028f406d7ece23c3b39e06c763ab08e Mon Sep 17 00:00:00 2001 From: vmangalr Date: Mon, 26 Jan 2026 13:19:41 +0530 Subject: [PATCH 5/6] [DI-29167] - Zoom key modification --- .../features/CloudPulse/Widget/CloudPulseWidget.tsx | 2 +- .../Widget/components/CloudPulseLineGraph.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 654e63f612d..9f188c4978b 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -475,7 +475,7 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { return `preset:${preset}`; } if (!start || !end || !timeZone) { - return 'custom:invalid'; + return 'custom:missing-params'; } return `custom:${start},${end},${timeZone}`; diff --git a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx index 1004865fc68..118aa38e028 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/CloudPulseLineGraph.tsx @@ -52,8 +52,12 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => { const isZoomEnabled = flags.aclp?.enableZoomInCharts ?? false; // default to false - const { zoom, isZoomed, zoomOut, zoomCallbacks } = - useZoomController(zoomResetKey); + const { + zoom, + isZoomed, + zoomOut: resetZoom, + zoomCallbacks, + } = useZoomController(zoomResetKey); const zoomedData = React.useMemo(() => { if (!isZoomEnabled) { @@ -114,7 +118,7 @@ export const CloudPulseLineGraph = React.memo((props: CloudPulseLineGraph) => {