mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
40ac0fa14b
commit
adcebcaf8c
@ -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(<FlameGraphWithProps />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
@ -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<HTMLCanvasElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [tooltipData, setTooltipData] = useState<TooltipData>();
|
||||
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<ContextMenuData>();
|
||||
|
||||
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 (
|
||||
<div className={styles.graph} ref={sizeRef}>
|
||||
<FlameGraphMetadata
|
||||
@ -214,15 +228,29 @@ const FlameGraph = ({
|
||||
valueField={valueField}
|
||||
totalTicks={totalTicks}
|
||||
/>
|
||||
<div className={styles.canvasContainer} id="flameGraphCanvasContainer">
|
||||
<canvas ref={graphRef} data-testid="flameGraph" />
|
||||
<FlameGraphTooltip tooltipRef={tooltipRef} tooltipData={tooltipData!} showTooltip={showTooltip} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
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.
|
||||
|
@ -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;
|
@ -9,15 +9,14 @@ import { TooltipData, SampleUnit } from '../types';
|
||||
type Props = {
|
||||
tooltipRef: LegacyRef<HTMLDivElement>;
|
||||
tooltipData: TooltipData;
|
||||
showTooltip: boolean;
|
||||
};
|
||||
|
||||
const FlameGraphTooltip = ({ tooltipRef, tooltipData, showTooltip }: Props) => {
|
||||
const FlameGraphTooltip = ({ tooltipRef, tooltipData }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div ref={tooltipRef} className={styles.tooltip}>
|
||||
{tooltipData && showTooltip && (
|
||||
{tooltipData && (
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user