FlameGraph: adds ability to add context menu items (#81675)

* pyroscope: adds ability to add context menu items

* moves things around

* removes console.log

* improvements

* Change the extra context button API shape

* Add test

* lint

---------

Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>
This commit is contained in:
Dmitry Filimonov
2024-03-14 03:00:30 -07:00
committed by GitHub
parent 40c2c2d6b8
commit 33154abcd9
7 changed files with 94 additions and 13 deletions

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { createDataFrame } from '@grafana/data'; import { createDataFrame } from '@grafana/data';
import { ColorScheme } from '../types'; import { ColorScheme, SelectedView } from '../types';
import FlameGraph from './FlameGraph'; import FlameGraph from './FlameGraph';
import { FlameGraphDataContainer } from './dataTransform'; import { FlameGraphDataContainer } from './dataTransform';
@@ -23,7 +23,7 @@ jest.mock('react-use', () => {
}); });
describe('FlameGraph', () => { describe('FlameGraph', () => {
function setup() { function setup(props?: Partial<React.ComponentProps<typeof FlameGraph>>) {
const flameGraphData = createDataFrame(data); const flameGraphData = createDataFrame(data);
const container = new FlameGraphDataContainer(flameGraphData, { collapsing: true }); const container = new FlameGraphDataContainer(flameGraphData, { collapsing: true });
@@ -47,6 +47,9 @@ describe('FlameGraph', () => {
onFocusPillClick={onFocusPillClick} onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick} onSandwichPillClick={onSandwichPillClick}
colorScheme={ColorScheme.ValueBased} colorScheme={ColorScheme.ValueBased}
selectedView={SelectedView.FlameGraph}
search={''}
{...props}
/> />
); );
return { return {
@@ -80,18 +83,31 @@ describe('FlameGraph', () => {
expect(screen.getByText('16.5 Bil | 16.5 Bil samples (Count)')).toBeDefined(); expect(screen.getByText('16.5 Bil | 16.5 Bil samples (Count)')).toBeDefined();
}); });
it('should render context menu', async () => { it('should render context menu + extra items', async () => {
const event = new MouseEvent('click', { bubbles: true }); const event = new MouseEvent('click', { bubbles: true });
Object.defineProperty(event, 'offsetX', { get: () => 10 }); Object.defineProperty(event, 'offsetX', { get: () => 10 });
Object.defineProperty(event, 'offsetY', { get: () => 10 }); Object.defineProperty(event, 'offsetY', { get: () => 10 });
Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, value: 500 }); Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, value: 500 });
setup(); setup({
getExtraContextMenuButtons: (clickedItemData, data, state) => {
expect(clickedItemData).toMatchObject({ posX: 0, posY: 0, label: 'total' });
expect(data.length).toEqual(1101);
expect(state).toEqual({
selectedView: SelectedView.FlameGraph,
isDiff: false,
search: '',
collapseConfig: undefined,
});
return [{ label: 'test extra item', icon: 'eye', onClick: () => {} }];
},
});
const canvas = screen.getByTestId('flameGraph') as HTMLCanvasElement; const canvas = screen.getByTestId('flameGraph') as HTMLCanvasElement;
expect(canvas).toBeInTheDocument(); expect(canvas).toBeInTheDocument();
expect(screen.queryByTestId('contextMenu')).not.toBeInTheDocument(); expect(screen.queryByTestId('contextMenu')).not.toBeInTheDocument();
fireEvent(canvas, event); fireEvent(canvas, event);
expect(screen.getByTestId('contextMenu')).toBeInTheDocument(); expect(screen.getByTestId('contextMenu')).toBeInTheDocument();
expect(screen.getByText('test extra item')).toBeInTheDocument();
}); });
}); });

View File

@@ -22,9 +22,10 @@ import React, { useEffect, useState } from 'react';
import { Icon } from '@grafana/ui'; import { Icon } from '@grafana/ui';
import { PIXELS_PER_LEVEL } from '../constants'; import { PIXELS_PER_LEVEL } from '../constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../types'; import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types';
import FlameGraphCanvas from './FlameGraphCanvas'; import FlameGraphCanvas from './FlameGraphCanvas';
import { GetExtraContextMenuButtonsFunction } from './FlameGraphContextMenu';
import FlameGraphMetadata from './FlameGraphMetadata'; import FlameGraphMetadata from './FlameGraphMetadata';
import { CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform'; import { CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
@@ -45,7 +46,10 @@ type Props = {
onSandwichPillClick: () => void; onSandwichPillClick: () => void;
colorScheme: ColorScheme | ColorSchemeDiff; colorScheme: ColorScheme | ColorSchemeDiff;
showFlameGraphOnly?: boolean; showFlameGraphOnly?: boolean;
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
collapsing?: boolean; collapsing?: boolean;
selectedView: SelectedView;
search: string;
}; };
const FlameGraph = ({ const FlameGraph = ({
@@ -64,7 +68,10 @@ const FlameGraph = ({
onSandwichPillClick, onSandwichPillClick,
colorScheme, colorScheme,
showFlameGraphOnly, showFlameGraphOnly,
getExtraContextMenuButtons,
collapsing, collapsing,
selectedView,
search,
}: Props) => { }: Props) => {
const styles = getStyles(); const styles = getStyles();
@@ -122,7 +129,10 @@ const FlameGraph = ({
showFlameGraphOnly, showFlameGraphOnly,
collapsedMap, collapsedMap,
setCollapsedMap, setCollapsedMap,
getExtraContextMenuButtons,
collapsing, collapsing,
search,
selectedView,
}; };
const canvas = levelsCallers ? ( const canvas = levelsCallers ? (
<> <>

View File

@@ -3,9 +3,9 @@ import React, { MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef, u
import { useMeasure } from 'react-use'; import { useMeasure } from 'react-use';
import { PIXELS_PER_LEVEL } from '../constants'; import { PIXELS_PER_LEVEL } from '../constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../types'; import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types';
import FlameGraphContextMenu from './FlameGraphContextMenu'; import FlameGraphContextMenu, { GetExtraContextMenuButtonsFunction } from './FlameGraphContextMenu';
import FlameGraphTooltip from './FlameGraphTooltip'; import FlameGraphTooltip from './FlameGraphTooltip';
import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform'; import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
import { getBarX, useFlameRender } from './rendering'; import { getBarX, useFlameRender } from './rendering';
@@ -37,6 +37,10 @@ type Props = {
collapsedMap: CollapsedMap; collapsedMap: CollapsedMap;
setCollapsedMap: (collapsedMap: CollapsedMap) => void; setCollapsedMap: (collapsedMap: CollapsedMap) => void;
collapsing?: boolean; collapsing?: boolean;
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
selectedView: SelectedView;
search: string;
}; };
const FlameGraphCanvas = ({ const FlameGraphCanvas = ({
@@ -61,6 +65,9 @@ const FlameGraphCanvas = ({
collapsedMap, collapsedMap,
setCollapsedMap, setCollapsedMap,
collapsing, collapsing,
getExtraContextMenuButtons,
selectedView,
search,
}: Props) => { }: Props) => {
const styles = getStyles(); const styles = getStyles();
@@ -186,6 +193,7 @@ const FlameGraphCanvas = ({
/> />
{!showFlameGraphOnly && clickedItemData && ( {!showFlameGraphOnly && clickedItemData && (
<FlameGraphContextMenu <FlameGraphContextMenu
data={data}
itemData={clickedItemData} itemData={clickedItemData}
collapsing={collapsing} collapsing={collapsing}
collapseConfig={collapsedMap.get(clickedItemData.item)} collapseConfig={collapsedMap.get(clickedItemData.item)}
@@ -214,6 +222,9 @@ const FlameGraphCanvas = ({
}} }}
allGroupsCollapsed={Array.from(collapsedMap.values()).every((i) => i.collapsed)} allGroupsCollapsed={Array.from(collapsedMap.values()).every((i) => i.collapsed)}
allGroupsExpanded={Array.from(collapsedMap.values()).every((i) => !i.collapsed)} allGroupsExpanded={Array.from(collapsedMap.values()).every((i) => !i.collapsed)}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
search={search}
/> />
)} )}
</div> </div>

View File

@@ -1,12 +1,26 @@
import React from 'react'; import React from 'react';
import { MenuItem, MenuGroup, ContextMenu } from '@grafana/ui'; import { DataFrame } from '@grafana/data';
import { MenuItem, MenuGroup, ContextMenu, IconName } from '@grafana/ui';
import { ClickedItemData } from '../types'; import { ClickedItemData, SelectedView } from '../types';
import { CollapseConfig } from './dataTransform'; import { CollapseConfig, FlameGraphDataContainer } from './dataTransform';
export type GetExtraContextMenuButtonsFunction = (
clickedItemData: ClickedItemData,
data: DataFrame,
state: { selectedView: SelectedView; isDiff: boolean; search: string; collapseConfig?: CollapseConfig }
) => ExtraContextMenuButton[];
export type ExtraContextMenuButton = {
label: string;
icon: IconName;
onClick: () => void;
};
type Props = { type Props = {
data: FlameGraphDataContainer;
itemData: ClickedItemData; itemData: ClickedItemData;
onMenuItemClick: () => void; onMenuItemClick: () => void;
onItemFocus: () => void; onItemFocus: () => void;
@@ -15,13 +29,17 @@ type Props = {
onCollapseGroup: () => void; onCollapseGroup: () => void;
onExpandAllGroups: () => void; onExpandAllGroups: () => void;
onCollapseAllGroups: () => void; onCollapseAllGroups: () => void;
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
collapseConfig?: CollapseConfig; collapseConfig?: CollapseConfig;
collapsing?: boolean; collapsing?: boolean;
allGroupsCollapsed?: boolean; allGroupsCollapsed?: boolean;
allGroupsExpanded?: boolean; allGroupsExpanded?: boolean;
selectedView: SelectedView;
search: string;
}; };
const FlameGraphContextMenu = ({ const FlameGraphContextMenu = ({
data,
itemData, itemData,
onMenuItemClick, onMenuItemClick,
onItemFocus, onItemFocus,
@@ -31,11 +49,21 @@ const FlameGraphContextMenu = ({
onCollapseGroup, onCollapseGroup,
onExpandAllGroups, onExpandAllGroups,
onCollapseAllGroups, onCollapseAllGroups,
getExtraContextMenuButtons,
collapsing, collapsing,
allGroupsExpanded, allGroupsExpanded,
allGroupsCollapsed, allGroupsCollapsed,
selectedView,
search,
}: Props) => { }: Props) => {
function renderItems() { function renderItems() {
const extraButtons =
getExtraContextMenuButtons?.(itemData, data.data, {
selectedView,
isDiff: data.isDiffFlamegraph(),
search,
collapseConfig,
}) || [];
return ( return (
<> <>
<MenuItem <MenuItem
@@ -63,7 +91,9 @@ const FlameGraphContextMenu = ({
onMenuItemClick(); onMenuItemClick();
}} }}
/> />
{extraButtons.map(({ label, icon, onClick }) => {
return <MenuItem label={label} icon={icon} onClick={() => onClick()} key={label} />;
})}
{collapsing && ( {collapsing && (
<MenuGroup label={'Grouping'}> <MenuGroup label={'Grouping'}>
{collapseConfig ? ( {collapseConfig ? (

View File

@@ -272,7 +272,7 @@ export class FlameGraphDataContainer {
} }
isDiffFlamegraph() { isDiffFlamegraph() {
return this.valueRightField && this.selfRightField; return Boolean(this.valueRightField && this.selfRightField);
} }
getLabel(index: number) { getLabel(index: number) {

View File

@@ -7,6 +7,7 @@ import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { ThemeContext } from '@grafana/ui'; import { ThemeContext } from '@grafana/ui';
import FlameGraph from './FlameGraph/FlameGraph'; import FlameGraph from './FlameGraph/FlameGraph';
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
import { FlameGraphDataContainer } from './FlameGraph/dataTransform'; import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
import FlameGraphHeader from './FlameGraphHeader'; import FlameGraphHeader from './FlameGraphHeader';
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer'; import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
@@ -52,6 +53,11 @@ export type Props = {
*/ */
extraHeaderElements?: React.ReactNode; extraHeaderElements?: React.ReactNode;
/**
* Extra buttons that will be shown in the context menu when user clicks on a Node.
*/
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
/** /**
* If true the flamegraph will be rendered on top of the table. * If true the flamegraph will be rendered on top of the table.
*/ */
@@ -80,6 +86,7 @@ const FlameGraphContainer = ({
vertical, vertical,
showFlameGraphOnly, showFlameGraphOnly,
disableCollapsing, disableCollapsing,
getExtraContextMenuButtons,
}: Props) => { }: Props) => {
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>(); const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
@@ -169,6 +176,9 @@ const FlameGraphContainer = ({
colorScheme={colorScheme} colorScheme={colorScheme}
showFlameGraphOnly={showFlameGraphOnly} showFlameGraphOnly={showFlameGraphOnly}
collapsing={!disableCollapsing} collapsing={!disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
search={search}
/> />
); );
@@ -238,7 +248,7 @@ const FlameGraphContainer = ({
stickyHeader={Boolean(stickyHeader)} stickyHeader={Boolean(stickyHeader)}
extraHeaderElements={extraHeaderElements} extraHeaderElements={extraHeaderElements}
vertical={vertical} vertical={vertical}
isDiffMode={Boolean(dataContainer.isDiffFlamegraph())} isDiffMode={dataContainer.isDiffFlamegraph()}
/> />
)} )}

View File

@@ -1,5 +1,9 @@
import { LevelItem } from './FlameGraph/dataTransform'; import { LevelItem } from './FlameGraph/dataTransform';
export { type FlameGraphDataContainer } from './FlameGraph/dataTransform';
export { type ExtraContextMenuButton } from './FlameGraph/FlameGraphContextMenu';
export type ClickedItemData = { export type ClickedItemData = {
posX: number; posX: number;
posY: number; posY: number;