diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.test.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.test.tsx index 609ddab52ae..ceb2855ab39 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.test.tsx +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.test.tsx @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/dom'; +import { fireEvent, screen } from '@testing-library/dom'; import { render } from '@testing-library/react'; import React, { useState } from 'react'; @@ -9,6 +9,7 @@ import { SelectedView } from '../types'; import FlameGraph from './FlameGraph'; import { Item, nestedSetToLevels } from './dataTransform'; import { data } from './testData/dataNestedSet'; + import 'jest-canvas-mock'; jest.mock('react-use', () => ({ @@ -67,4 +68,18 @@ describe('FlameGraph', () => { render(); expect(screen.getByText('16.5 Bil (100%) of 16,460,000,000 total samples (Count)')).toBeDefined(); }); + + it('should render context menu', async () => { + const event = new MouseEvent('click'); + Object.defineProperty(event, 'offsetX', { get: () => 10 }); + Object.defineProperty(event, 'offsetY', { get: () => 10 }); + Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, value: 500 }); + + const screen = render(); + const canvas = screen.getByTestId('flameGraph') as HTMLCanvasElement; + expect(canvas).toBeInTheDocument(); + expect(screen.queryByTestId('contextMenu')).not.toBeInTheDocument(); + fireEvent(canvas, event); + expect(screen.getByTestId('contextMenu')).toBeInTheDocument(); + }); }); diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx index 63a953c6c8c..22d150e2463 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraph.tsx @@ -24,8 +24,9 @@ import { useMeasure } from 'react-use'; import { CoreApp, createTheme, DataFrame, FieldType, getDisplayProcessor } from '@grafana/data'; import { PIXELS_PER_LEVEL } from '../../constants'; -import { TooltipData, SelectedView } from '../types'; +import { TooltipData, SelectedView, ContextMenuData } from '../types'; +import FlameGraphContextMenu from './FlameGraphContextMenu'; import FlameGraphMetadata from './FlameGraphMetadata'; import FlameGraphTooltip, { getTooltipData } from './FlameGraphTooltip'; import { ItemWithStart } from './dataTransform'; @@ -77,18 +78,7 @@ const FlameGraph = ({ const graphRef = useRef(null); const tooltipRef = useRef(null); const [tooltipData, setTooltipData] = useState(); - const [showTooltip, setShowTooltip] = useState(false); - - // Convert pixel coordinates to bar coordinates in the levels array so that we can add mouse events like clicks to - // the canvas. - const convertPixelCoordinatesToBarCoordinates = useCallback( - (x: number, y: number, pixelsPerTick: number) => { - const levelIndex = Math.floor(y / (PIXELS_PER_LEVEL / window.devicePixelRatio)); - const barIndex = getBarIndex(x, levels[levelIndex], pixelsPerTick, totalTicks, rangeMin); - return { levelIndex, barIndex }; - }, - [levels, totalTicks, rangeMin] - ); + const [contextMenuData, setContextMenuData] = useState(); const [ufuzzy] = useState(() => { return new uFuzzy(); @@ -152,25 +142,37 @@ const FlameGraph = ({ const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalTicks / (rangeMax - rangeMin); render(pixelsPerTick); - // Clicking allows user to "zoom" into the flamegraph. Zooming means the x axis gets smaller so that the clicked - // bar takes 100% of the x axis. graphRef.current.onclick = (e) => { + setTooltipData(undefined); const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin); - const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(e.offsetX, e.offsetY, pixelsPerTick); + const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates( + e, + pixelsPerTick, + levels, + totalTicks, + rangeMin + ); + // if clicking on a block in the canvas if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) { - setTopLevelIndex(levelIndex); - setSelectedBarIndex(barIndex); - setRangeMin(levels[levelIndex][barIndex].start / totalTicks); - setRangeMax((levels[levelIndex][barIndex].start + levels[levelIndex][barIndex].value) / totalTicks); + setContextMenuData({ e, levelIndex, barIndex }); + } else { + // if clicking on the canvas but there is no block beneath the cursor + setContextMenuData(undefined); } }; graphRef.current!.onmousemove = (e) => { - if (tooltipRef.current) { - setShowTooltip(false); + if (tooltipRef.current && contextMenuData === undefined) { + setTooltipData(undefined); const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin); - const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates(e.offsetX, e.offsetY, pixelsPerTick); + const { levelIndex, barIndex } = convertPixelCoordinatesToBarCoordinates( + e, + pixelsPerTick, + levels, + totalTicks, + rangeMin + ); if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) { tooltipRef.current.style.left = e.clientX + 10 + 'px'; @@ -179,18 +181,16 @@ const FlameGraph = ({ const bar = levels[levelIndex][barIndex]; const tooltipData = getTooltipData(valueField, bar.label, bar.value, bar.self, totalTicks); setTooltipData(tooltipData); - setShowTooltip(true); } } }; graphRef.current!.onmouseleave = () => { - setShowTooltip(false); + setTooltipData(undefined); }; } }, [ render, - convertPixelCoordinatesToBarCoordinates, levels, rangeMin, rangeMax, @@ -203,8 +203,22 @@ const FlameGraph = ({ selectedView, valueField, setSelectedBarIndex, + setContextMenuData, + contextMenuData, ]); + // hide context menu if outside of the flame graph canvas is clicked + useEffect(() => { + const handleOnClick = (e: MouseEvent) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + if ((e.target as HTMLElement).parentElement?.id !== 'flameGraphCanvasContainer') { + setContextMenuData(undefined); + } + }; + window.addEventListener('click', handleOnClick); + return () => window.removeEventListener('click', handleOnClick); + }, [setContextMenuData]); + return (
- - +
+ +
+ + {contextMenuData && ( + + )}
); }; const getStyles = (selectedView: SelectedView, app: CoreApp, flameGraphHeight: number | undefined) => ({ graph: css` - cursor: pointer; float: left; overflow: scroll; width: ${selectedView === SelectedView.FlameGraph ? '100%' : '50%'}; @@ -230,8 +258,25 @@ const getStyles = (selectedView: SelectedView, app: CoreApp, flameGraphHeight: n ? `height: calc(${flameGraphHeight}px - 50px)` : ''}; // 50px to adjust for header pushing content down `, + canvasContainer: css` + cursor: pointer; + `, }); +// Convert pixel coordinates to bar coordinates in the levels array so that we can add mouse events like clicks to +// the canvas. +const convertPixelCoordinatesToBarCoordinates = ( + e: MouseEvent, + pixelsPerTick: number, + levels: ItemWithStart[][], + totalTicks: number, + rangeMin: number +) => { + const levelIndex = Math.floor(e.offsetY / (PIXELS_PER_LEVEL / window.devicePixelRatio)); + const barIndex = getBarIndex(e.offsetX, levels[levelIndex], pixelsPerTick, totalTicks, rangeMin); + return { levelIndex, barIndex }; +}; + /** * Binary search for a bar in a level, based on the X pixel coordinate. Useful for detecting which bar did user click * on. diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphContextMenu.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphContextMenu.tsx new file mode 100644 index 00000000000..e3ecddcebda --- /dev/null +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphContextMenu.tsx @@ -0,0 +1,82 @@ +import React from 'react'; + +import { MenuItem, ContextMenu } from '@grafana/ui'; + +import { ContextMenuData } from '../types'; + +import { ItemWithStart } from './dataTransform'; + +type Props = { + contextMenuData: ContextMenuData; + levels: ItemWithStart[][]; + totalTicks: number; + graphRef: React.RefObject; + setContextMenuData: (event: ContextMenuData | undefined) => void; + setTopLevelIndex: (level: number) => void; + setSelectedBarIndex: (bar: number) => void; + setRangeMin: (range: number) => void; + setRangeMax: (range: number) => void; +}; + +const FlameGraphContextMenu = ({ + contextMenuData, + graphRef, + totalTicks, + levels, + setContextMenuData, + setTopLevelIndex, + setSelectedBarIndex, + setRangeMin, + setRangeMax, +}: Props) => { + const renderMenuItems = () => { + return ( + <> + { + if (graphRef.current && contextMenuData) { + setTopLevelIndex(contextMenuData.levelIndex); + setSelectedBarIndex(contextMenuData.barIndex); + setRangeMin(levels[contextMenuData.levelIndex][contextMenuData.barIndex].start / totalTicks); + setRangeMax( + (levels[contextMenuData.levelIndex][contextMenuData.barIndex].start + + levels[contextMenuData.levelIndex][contextMenuData.barIndex].value) / + totalTicks + ); + setContextMenuData(undefined); + } + }} + /> + { + if (graphRef.current && contextMenuData) { + const bar = levels[contextMenuData.levelIndex][contextMenuData.barIndex]; + navigator.clipboard.writeText(bar.label).then(() => { + setContextMenuData(undefined); + }); + } + }} + /> + + ); + }; + + return ( +
+ {contextMenuData.e.clientX && contextMenuData.e.clientY && ( + renderMenuItems()} + x={contextMenuData.e.clientX + 10} + y={contextMenuData.e.clientY} + focusOnOpen={false} + > + )} +
+ ); +}; + +export default FlameGraphContextMenu; diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphTooltip.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphTooltip.tsx index 15a782940df..ff113ab397b 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphTooltip.tsx +++ b/public/app/plugins/panel/flamegraph/components/FlameGraph/FlameGraphTooltip.tsx @@ -9,15 +9,14 @@ import { TooltipData, SampleUnit } from '../types'; type Props = { tooltipRef: LegacyRef; tooltipData: TooltipData; - showTooltip: boolean; }; -const FlameGraphTooltip = ({ tooltipRef, tooltipData, showTooltip }: Props) => { +const FlameGraphTooltip = ({ tooltipRef, tooltipData }: Props) => { const styles = useStyles2(getStyles); return (
- {tooltipData && showTooltip && ( + {tooltipData && ( diff --git a/public/app/plugins/panel/flamegraph/components/types.ts b/public/app/plugins/panel/flamegraph/components/types.ts index be1a0900a83..e1bc75d32ef 100644 --- a/public/app/plugins/panel/flamegraph/components/types.ts +++ b/public/app/plugins/panel/flamegraph/components/types.ts @@ -8,6 +8,12 @@ export type TooltipData = { samples: string; }; +export type ContextMenuData = { + e: MouseEvent; + levelIndex: number; + barIndex: number; +}; + export type Metadata = { percentValue: number; unitTitle: string;