Flame graph: Add context menu (#62705)

* Context menu

* Flame graph context menu improvements

* Move context menu state into flame graph

* Simplify logic

* Add test and rename to ContextMenuData
This commit is contained in:
Joey 2023-03-02 09:47:56 +00:00 committed by GitHub
parent 40ac0fa14b
commit adcebcaf8c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 180 additions and 33 deletions

View File

@ -1,4 +1,4 @@
import { screen } from '@testing-library/dom'; import { fireEvent, screen } from '@testing-library/dom';
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import React, { useState } from 'react'; import React, { useState } from 'react';
@ -9,6 +9,7 @@ import { SelectedView } from '../types';
import FlameGraph from './FlameGraph'; import FlameGraph from './FlameGraph';
import { Item, nestedSetToLevels } from './dataTransform'; import { Item, nestedSetToLevels } from './dataTransform';
import { data } from './testData/dataNestedSet'; import { data } from './testData/dataNestedSet';
import 'jest-canvas-mock'; import 'jest-canvas-mock';
jest.mock('react-use', () => ({ jest.mock('react-use', () => ({
@ -67,4 +68,18 @@ describe('FlameGraph', () => {
render(<FlameGraphWithProps />); render(<FlameGraphWithProps />);
expect(screen.getByText('16.5 Bil (100%) of 16,460,000,000 total samples (Count)')).toBeDefined(); 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(<FlameGraphWithProps />);
const canvas = screen.getByTestId('flameGraph') as HTMLCanvasElement;
expect(canvas).toBeInTheDocument();
expect(screen.queryByTestId('contextMenu')).not.toBeInTheDocument();
fireEvent(canvas, event);
expect(screen.getByTestId('contextMenu')).toBeInTheDocument();
});
}); });

View File

@ -24,8 +24,9 @@ import { useMeasure } from 'react-use';
import { CoreApp, createTheme, DataFrame, FieldType, getDisplayProcessor } from '@grafana/data'; import { CoreApp, createTheme, DataFrame, FieldType, getDisplayProcessor } from '@grafana/data';
import { PIXELS_PER_LEVEL } from '../../constants'; 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 FlameGraphMetadata from './FlameGraphMetadata';
import FlameGraphTooltip, { getTooltipData } from './FlameGraphTooltip'; import FlameGraphTooltip, { getTooltipData } from './FlameGraphTooltip';
import { ItemWithStart } from './dataTransform'; import { ItemWithStart } from './dataTransform';
@ -77,18 +78,7 @@ const FlameGraph = ({
const graphRef = useRef<HTMLCanvasElement>(null); const graphRef = useRef<HTMLCanvasElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null); const tooltipRef = useRef<HTMLDivElement>(null);
const [tooltipData, setTooltipData] = useState<TooltipData>(); const [tooltipData, setTooltipData] = useState<TooltipData>();
const [showTooltip, setShowTooltip] = useState(false); const [contextMenuData, setContextMenuData] = useState<ContextMenuData>();
// 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 [ufuzzy] = useState(() => { const [ufuzzy] = useState(() => {
return new uFuzzy(); return new uFuzzy();
@ -152,25 +142,37 @@ const FlameGraph = ({
const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalTicks / (rangeMax - rangeMin); const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalTicks / (rangeMax - rangeMin);
render(pixelsPerTick); 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) => { graphRef.current.onclick = (e) => {
setTooltipData(undefined);
const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin); 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)) { if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
setTopLevelIndex(levelIndex); setContextMenuData({ e, levelIndex, barIndex });
setSelectedBarIndex(barIndex); } else {
setRangeMin(levels[levelIndex][barIndex].start / totalTicks); // if clicking on the canvas but there is no block beneath the cursor
setRangeMax((levels[levelIndex][barIndex].start + levels[levelIndex][barIndex].value) / totalTicks); setContextMenuData(undefined);
} }
}; };
graphRef.current!.onmousemove = (e) => { graphRef.current!.onmousemove = (e) => {
if (tooltipRef.current) { if (tooltipRef.current && contextMenuData === undefined) {
setShowTooltip(false); setTooltipData(undefined);
const pixelsPerTick = graphRef.current!.clientWidth / totalTicks / (rangeMax - rangeMin); 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)) { if (barIndex !== -1 && !isNaN(levelIndex) && !isNaN(barIndex)) {
tooltipRef.current.style.left = e.clientX + 10 + 'px'; tooltipRef.current.style.left = e.clientX + 10 + 'px';
@ -179,18 +181,16 @@ const FlameGraph = ({
const bar = levels[levelIndex][barIndex]; const bar = levels[levelIndex][barIndex];
const tooltipData = getTooltipData(valueField, bar.label, bar.value, bar.self, totalTicks); const tooltipData = getTooltipData(valueField, bar.label, bar.value, bar.self, totalTicks);
setTooltipData(tooltipData); setTooltipData(tooltipData);
setShowTooltip(true);
} }
} }
}; };
graphRef.current!.onmouseleave = () => { graphRef.current!.onmouseleave = () => {
setShowTooltip(false); setTooltipData(undefined);
}; };
} }
}, [ }, [
render, render,
convertPixelCoordinatesToBarCoordinates,
levels, levels,
rangeMin, rangeMin,
rangeMax, rangeMax,
@ -203,8 +203,22 @@ const FlameGraph = ({
selectedView, selectedView,
valueField, valueField,
setSelectedBarIndex, 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 ( return (
<div className={styles.graph} ref={sizeRef}> <div className={styles.graph} ref={sizeRef}>
<FlameGraphMetadata <FlameGraphMetadata
@ -214,15 +228,29 @@ const FlameGraph = ({
valueField={valueField} valueField={valueField}
totalTicks={totalTicks} totalTicks={totalTicks}
/> />
<canvas ref={graphRef} data-testid="flameGraph" /> <div className={styles.canvasContainer} id="flameGraphCanvasContainer">
<FlameGraphTooltip tooltipRef={tooltipRef} tooltipData={tooltipData!} showTooltip={showTooltip} /> <canvas ref={graphRef} data-testid="flameGraph" />
</div>
<FlameGraphTooltip tooltipRef={tooltipRef} tooltipData={tooltipData!} />
{contextMenuData && (
<FlameGraphContextMenu
contextMenuData={contextMenuData!}
levels={levels}
totalTicks={totalTicks}
graphRef={graphRef}
setContextMenuData={setContextMenuData}
setTopLevelIndex={setTopLevelIndex}
setSelectedBarIndex={setSelectedBarIndex}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
/>
)}
</div> </div>
); );
}; };
const getStyles = (selectedView: SelectedView, app: CoreApp, flameGraphHeight: number | undefined) => ({ const getStyles = (selectedView: SelectedView, app: CoreApp, flameGraphHeight: number | undefined) => ({
graph: css` graph: css`
cursor: pointer;
float: left; float: left;
overflow: scroll; overflow: scroll;
width: ${selectedView === SelectedView.FlameGraph ? '100%' : '50%'}; width: ${selectedView === SelectedView.FlameGraph ? '100%' : '50%'};
@ -230,8 +258,25 @@ const getStyles = (selectedView: SelectedView, app: CoreApp, flameGraphHeight: n
? `height: calc(${flameGraphHeight}px - 50px)` ? `height: calc(${flameGraphHeight}px - 50px)`
: ''}; // 50px to adjust for header pushing content down : ''}; // 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 * Binary search for a bar in a level, based on the X pixel coordinate. Useful for detecting which bar did user click
* on. * on.

View File

@ -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<HTMLCanvasElement>;
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 (
<>
<MenuItem
label="Focus block"
icon={'eye'}
onClick={() => {
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);
}
}}
/>
<MenuItem
label="Copy function name"
icon={'copy'}
onClick={() => {
if (graphRef.current && contextMenuData) {
const bar = levels[contextMenuData.levelIndex][contextMenuData.barIndex];
navigator.clipboard.writeText(bar.label).then(() => {
setContextMenuData(undefined);
});
}
}}
/>
</>
);
};
return (
<div data-testid="contextMenu">
{contextMenuData.e.clientX && contextMenuData.e.clientY && (
<ContextMenu
renderMenuItems={() => renderMenuItems()}
x={contextMenuData.e.clientX + 10}
y={contextMenuData.e.clientY}
focusOnOpen={false}
></ContextMenu>
)}
</div>
);
};
export default FlameGraphContextMenu;

View File

@ -9,15 +9,14 @@ import { TooltipData, SampleUnit } from '../types';
type Props = { type Props = {
tooltipRef: LegacyRef<HTMLDivElement>; tooltipRef: LegacyRef<HTMLDivElement>;
tooltipData: TooltipData; tooltipData: TooltipData;
showTooltip: boolean;
}; };
const FlameGraphTooltip = ({ tooltipRef, tooltipData, showTooltip }: Props) => { const FlameGraphTooltip = ({ tooltipRef, tooltipData }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
<div ref={tooltipRef} className={styles.tooltip}> <div ref={tooltipRef} className={styles.tooltip}>
{tooltipData && showTooltip && ( {tooltipData && (
<Tooltip <Tooltip
content={ content={
<div> <div>

View File

@ -8,6 +8,12 @@ export type TooltipData = {
samples: string; samples: string;
}; };
export type ContextMenuData = {
e: MouseEvent;
levelIndex: number;
barIndex: number;
};
export type Metadata = { export type Metadata = {
percentValue: number; percentValue: number;
unitTitle: string; unitTitle: string;