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 { 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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.
|
||||||
|
@ -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 = {
|
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>
|
||||||
|
@ -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;
|
||||||
|
Loading…
Reference in New Issue
Block a user