FlameGraph: Add prop to keep focused items when the profile data changes (#98356)

This commit is contained in:
Marc M. 2025-01-20 11:34:48 +01:00 committed by GitHub
parent b3b044b54b
commit 27cdd25917
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 131 additions and 80 deletions

View File

@ -26,17 +26,18 @@ import { Flamegraph } from '@grafana/flamegraph';
#### Props
| Name | Type | Description |
| ------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| data | DataFrame | DataFrame with the profile data. Optional, if missing or empty the flamegraph is not rendered |
| stickyHeader | boolean | Whether the header should be sticky and be always visible on the top when scrolling. |
| getTheme | () => GrafanaTheme2 | Provides a theme for the visualization on which colors and some sizes are based. |
| onTableSymbolClick | (symbol: string) => void | Interaction hook that can be used to report on the interaction. Fires when user click on a name in the table. |
| onViewSelected | (view: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the view to show (table/graph/both) |
| onTextAlignSelected | (align: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the text align. |
| onTableSort | (sort: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the teble sorting. |
| extraHeaderElements | React.ReactNode | Elements that will be shown in the header on the right side of the header buttons. Useful for additional functionality. |
| vertical | boolean | If true the flamegraph will be rendered on top of the table. |
| Name | Type | Description |
| --------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| data | DataFrame | DataFrame with the profile data. Optional, if missing or empty the flamegraph is not rendered |
| stickyHeader | boolean | Whether the header should be sticky and be always visible on the top when scrolling. |
| getTheme | () => GrafanaTheme2 | Provides a theme for the visualization on which colors and some sizes are based. |
| onTableSymbolClick | (symbol: string) => void | Interaction hook that can be used to report on the interaction. Fires when user click on a name in the table. |
| onViewSelected | (view: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the view to show (table/graph/both) |
| onTextAlignSelected | (align: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the text align. |
| onTableSort | (sort: string) => void | Interaction hook that can be used to report on the interaction. Fires when user changes the teble sorting. |
| extraHeaderElements | React.ReactNode | Elements that will be shown in the header on the right side of the header buttons. Useful for additional functionality. |
| vertical | boolean | If true the flamegraph will be rendered on top of the table. |
| keepFocusOnDataChange | boolean | If true any focused block will stay focused when the profile data changes. Same for the sandwich view. |
##### DataFrame schema

View File

@ -136,40 +136,46 @@ const FlameGraph = ({
search,
selectedView,
};
const canvas = levelsCallers ? (
<>
<div className={styles.sandwichCanvasWrapper}>
<div className={styles.sandwichMarker}>
Callers
<Icon className={styles.sandwichMarkerIcon} name={'arrow-down'} />
</div>
<FlameGraphCanvas
{...commonCanvasProps}
root={levelsCallers[levelsCallers.length - 1][0]}
depth={levelsCallers.length}
direction={'parents'}
// We do not support collapsing in sandwich view for now.
collapsing={false}
/>
</div>
let canvas = null;
<div className={styles.sandwichCanvasWrapper}>
<div className={cx(styles.sandwichMarker, styles.sandwichMarkerCalees)}>
<Icon className={styles.sandwichMarkerIcon} name={'arrow-up'} />
Callees
if (levelsCallers?.length) {
canvas = (
<>
<div className={styles.sandwichCanvasWrapper}>
<div className={styles.sandwichMarker}>
Callers
<Icon className={styles.sandwichMarkerIcon} name={'arrow-down'} />
</div>
<FlameGraphCanvas
{...commonCanvasProps}
root={levelsCallers[levelsCallers.length - 1][0]}
depth={levelsCallers.length}
direction={'parents'}
// We do not support collapsing in sandwich view for now.
collapsing={false}
/>
</div>
<FlameGraphCanvas
{...commonCanvasProps}
root={levels[0][0]}
depth={levels.length}
direction={'children'}
collapsing={false}
/>
</div>
</>
) : (
<FlameGraphCanvas {...commonCanvasProps} root={levels[0][0]} depth={levels.length} direction={'children'} />
);
<div className={styles.sandwichCanvasWrapper}>
<div className={cx(styles.sandwichMarker, styles.sandwichMarkerCalees)}>
<Icon className={styles.sandwichMarkerIcon} name={'arrow-up'} />
Callees
</div>
<FlameGraphCanvas
{...commonCanvasProps}
root={levels[0][0]}
depth={levels.length}
direction={'children'}
collapsing={false}
/>
</div>
</>
);
} else if (levels?.length) {
canvas = (
<FlameGraphCanvas {...commonCanvasProps} root={levels[0][0]} depth={levels.length} direction={'children'} />
);
}
return (
<div className={styles.graph}>

View File

@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { memo, ReactNode } from 'react';
import { getValueFormat, GrafanaTheme2 } from '@grafana/data';
import { Icon, IconButton, useStyles2 } from '@grafana/ui';
import { Icon, IconButton, Tooltip, useStyles2 } from '@grafana/ui';
import { ClickedItemData } from '../types';
@ -42,43 +42,50 @@ const FlameGraphMetadata = memo(
if (sandwichedLabel) {
parts.push(
<span key={'sandwich'}>
<Icon size={'sm'} name={'angle-right'} />
<div className={styles.metadataPill}>
<Icon size={'sm'} name={'gf-show-context'} />{' '}
<span className={styles.metadataPillName}>
{sandwichedLabel.substring(sandwichedLabel.lastIndexOf('/') + 1)}
</span>
<IconButton
className={styles.pillCloseButton}
name={'times'}
size={'sm'}
onClick={onSandwichPillClick}
tooltip={'Remove sandwich view'}
aria-label={'Remove sandwich view'}
/>
<Tooltip key={'sandwich'} content={sandwichedLabel} placement="top">
<div>
<Icon size={'sm'} name={'angle-right'} />
<div className={styles.metadataPill}>
<Icon size={'sm'} name={'gf-show-context'} />{' '}
<span className={styles.metadataPillName}>
{sandwichedLabel.substring(sandwichedLabel.lastIndexOf('/') + 1)}
</span>
<IconButton
className={styles.pillCloseButton}
name={'times'}
size={'sm'}
onClick={onSandwichPillClick}
tooltip={'Remove sandwich view'}
aria-label={'Remove sandwich view'}
/>
</div>
</div>
</span>
</Tooltip>
);
}
if (focusedItem) {
const percentValue = Math.round(10000 * (focusedItem.item.value / totalTicks)) / 100;
const percentValue = totalTicks > 0 ? Math.round(10000 * (focusedItem.item.value / totalTicks)) / 100 : 0;
const iconName = percentValue > 0 ? 'eye' : 'exclamation-circle';
parts.push(
<span key={'focus'}>
<Icon size={'sm'} name={'angle-right'} />
<div className={styles.metadataPill}>
<Icon size={'sm'} name={'eye'} /> {percentValue}% of total
<IconButton
className={styles.pillCloseButton}
name={'times'}
size={'sm'}
onClick={onFocusPillClick}
tooltip={'Remove focus'}
aria-label={'Remove focus'}
/>
<Tooltip key={'focus'} content={focusedItem.label} placement="top">
<div>
<Icon size={'sm'} name={'angle-right'} />
<div className={styles.metadataPill}>
<Icon size={'sm'} name={iconName} />
&nbsp;{percentValue}% of total
<IconButton
className={styles.pillCloseButton}
name={'times'}
size={'sm'}
onClick={onFocusPillClick}
tooltip={'Remove focus'}
aria-label={'Remove focus'}
/>
</div>
</div>
</span>
</Tooltip>
);
}
@ -107,8 +114,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
margin: theme.spacing(0, 0.5),
}),
metadata: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '8px 0',
textAlign: 'center',
}),
metadataPillName: css({
label: 'metadataPillName',

View File

@ -73,6 +73,10 @@ export type Props = {
* Disable behaviour where similar items in the same stack will be collapsed into single item.
*/
disableCollapsing?: boolean;
/**
* Whether or not to keep any focused item when the profile data changes.
*/
keepFocusOnDataChange?: boolean;
};
const FlameGraphContainer = ({
@ -87,6 +91,7 @@ const FlameGraphContainer = ({
vertical,
showFlameGraphOnly,
disableCollapsing,
keepFocusOnDataChange,
getExtraContextMenuButtons,
}: Props) => {
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
@ -133,14 +138,44 @@ const FlameGraphContainer = ({
setRangeMax(1);
}, [setFocusedItemData, setRangeMax, setRangeMin]);
function resetSandwich() {
const resetSandwich = useCallback(() => {
setSandwichItem(undefined);
}
}, [setSandwichItem]);
useEffect(() => {
resetFocus();
resetSandwich();
}, [data, resetFocus]);
if (!keepFocusOnDataChange) {
resetFocus();
resetSandwich();
return;
}
if (dataContainer && focusedItemData) {
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
if (item) {
setFocusedItemData({ ...focusedItemData, item });
const levels = dataContainer.getLevels();
const totalViewTicks = levels.length ? levels[0][0].value : 0;
setRangeMin(item.start / totalViewTicks);
setRangeMax((item.start + item.value) / totalViewTicks);
} else {
setFocusedItemData({
...focusedItemData,
item: {
start: 0,
value: 0,
itemIndexes: [],
children: [],
level: 0,
},
});
setRangeMin(0);
setRangeMax(1);
}
}
}, [dataContainer, keepFocusOnDataChange]); // eslint-disable-line react-hooks/exhaustive-deps
const onSymbolClick = useCallback(
(symbol: string) => {