Add call tree to flame graph container

This commit is contained in:
Aleksandar Petrov
2026-01-14 09:31:40 -04:00
parent 5bed426fd8
commit 17817bdda7
11 changed files with 488 additions and 353 deletions
+2
View File
@@ -58,6 +58,7 @@
"d3": "^7.8.5",
"lodash": "4.17.21",
"react": "18.3.1",
"react-table": "^7.8.0",
"react-use": "17.6.0",
"react-virtualized-auto-sizer": "1.0.26",
"tinycolor2": "1.6.0",
@@ -81,6 +82,7 @@
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/react": "18.3.18",
"@types/react-table": "^7.7.20",
"@types/react-virtualized-auto-sizer": "1.0.8",
"@types/tinycolor2": "1.4.6",
"babel-jest": "29.7.0",
@@ -13,6 +13,7 @@ const meta: Meta<typeof FlameGraphCallTreeContainer> = {
component: FlameGraphCallTreeContainer,
args: {
colorScheme: ColorScheme.PackageBased,
search: '',
},
decorators: [
(Story) => (
@@ -4,7 +4,7 @@ import { useTable, useSortBy, useExpanded, Column, Row, UseExpandedRowProps } fr
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Input, useStyles2, useTheme2 } from '@grafana/ui';
import { Button, useStyles2, useTheme2 } from '@grafana/ui';
import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from '../FlameGraph/colors';
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
@@ -23,10 +23,12 @@ type Props = {
onSandwich: (str?: string) => void;
onTableSort?: (sort: string) => void;
colorScheme: ColorScheme | ColorSchemeDiff;
search: string;
compact?: boolean;
};
const FlameGraphCallTreeContainer = memo(
({ data, onSymbolClick, sandwichItem, onSandwich, onTableSort, colorScheme: initialColorScheme }: Props) => {
({ data, onSymbolClick, sandwichItem, onSandwich, onTableSort, colorScheme: initialColorScheme, search, compact = false }: Props) => {
const styles = useStyles2(getStyles);
const theme = useTheme2();
@@ -42,8 +44,8 @@ const FlameGraphCallTreeContainer = memo(
// Callers state - track which function's callers we're showing
const [callersNodeLabel, setCallersNodeLabel] = useState<string | undefined>(undefined);
// Search state
const [searchQuery, setSearchQuery] = useState<string>('');
// Search state - use search from parent (shared with TopTable and FlameGraph)
const searchQuery = search;
const [currentMatchIndex, setCurrentMatchIndex] = useState<number>(0);
const [searchError, setSearchError] = useState<string | undefined>(undefined);
@@ -235,12 +237,6 @@ const FlameGraphCallTreeContainer = memo(
}
};
const clearSearch = () => {
setSearchQuery('');
setCurrentMatchIndex(-1);
setSearchError(undefined);
};
// Get current search match node ID
const currentSearchMatchId = useMemo(() => {
if (searchNodes.length > 0 && currentMatchIndex >= 0 && currentMatchIndex < searchNodes.length) {
@@ -349,7 +345,7 @@ const FlameGraphCallTreeContainer = memo(
// Define columns
const columns = useMemo<Column<CallTreeNode>[]>(() => {
if (data.isDiffFlamegraph()) {
return [
const cols: Column<CallTreeNode>[] = [
{
Header: '',
id: 'actions',
@@ -381,12 +377,16 @@ const FlameGraphCallTreeContainer = memo(
onSymbolClick={onSymbolClick}
styles={styles}
allNodes={nodes}
compact={compact}
/>
),
minWidth: 200,
width: undefined,
},
{
];
if (!compact) {
cols.push({
Header: '',
id: 'colorBar',
Cell: ({ row }: { row: Row<CallTreeNode> }) => (
@@ -395,7 +395,10 @@ const FlameGraphCallTreeContainer = memo(
minWidth: 200,
width: 200,
disableSortBy: true,
},
});
}
cols.push(
{
Header: 'Baseline %',
accessor: 'selfPercent',
@@ -418,10 +421,12 @@ const FlameGraphCallTreeContainer = memo(
),
sortType: 'basic',
width: 100,
},
];
}
);
return cols;
} else {
return [
const cols: Column<CallTreeNode>[] = [
{
Header: '',
id: 'actions',
@@ -453,58 +458,67 @@ const FlameGraphCallTreeContainer = memo(
onSymbolClick={onSymbolClick}
styles={styles}
allNodes={nodes}
compact={compact}
/>
),
minWidth: 200,
width: undefined,
},
{
Header: '',
id: 'colorBar',
Cell: ({ row }: { row: Row<CallTreeNode> }) => (
<ColorBarCell node={row.original} data={data} colorScheme={colorScheme} theme={theme} styles={styles} focusedNode={focusedNode} callersNode={callersNode} />
),
minWidth: 200,
width: 200,
disableSortBy: true,
},
{
Header: 'Self',
accessor: 'self',
Cell: ({ row }: { row: Row<CallTreeNode> }) => {
const displaySelf = data.getSelfDisplay([row.original.levelItem.itemIndexes[0]]);
const formattedValue = displaySelf.suffix ? displaySelf.text + displaySelf.suffix : displaySelf.text;
return (
<div className={styles.valueCell}>
<span className={styles.valueNumber}>{formattedValue}</span>
<span className={styles.percentNumber}>{row.original.selfPercent.toFixed(2)}%</span>
</div>
);
},
sortType: 'basic',
minWidth: 120,
width: 120,
},
{
Header: 'Total',
accessor: 'total',
Cell: ({ row }: { row: Row<CallTreeNode> }) => {
const displayValue = data.valueDisplayProcessor(row.original.total);
const formattedValue = displayValue.suffix ? displayValue.text + displayValue.suffix : displayValue.text;
return (
<div className={styles.valueCell}>
<span className={styles.valueNumber}>{formattedValue}</span>
<span className={styles.percentNumber}>{row.original.totalPercent.toFixed(2)}%</span>
</div>
);
},
sortType: 'basic',
minWidth: 120,
width: 120,
},
];
if (!compact) {
cols.push(
{
Header: '',
id: 'colorBar',
Cell: ({ row }: { row: Row<CallTreeNode> }) => (
<ColorBarCell node={row.original} data={data} colorScheme={colorScheme} theme={theme} styles={styles} focusedNode={focusedNode} callersNode={callersNode} />
),
minWidth: 200,
width: 200,
disableSortBy: true,
},
{
Header: 'Self',
accessor: 'self',
Cell: ({ row }: { row: Row<CallTreeNode> }) => {
const displaySelf = data.getSelfDisplay([row.original.levelItem.itemIndexes[0]]);
const formattedValue = displaySelf.suffix ? displaySelf.text + displaySelf.suffix : displaySelf.text;
return (
<div className={styles.valueCell}>
<span className={styles.valueNumber}>{formattedValue}</span>
<span className={styles.percentNumber}>{row.original.selfPercent.toFixed(2)}%</span>
</div>
);
},
sortType: 'basic',
minWidth: 120,
width: 120,
}
);
}
cols.push({
Header: 'Total',
accessor: 'total',
Cell: ({ row }: { row: Row<CallTreeNode> }) => {
const displayValue = data.valueDisplayProcessor(row.original.total);
const formattedValue = displayValue.suffix ? displayValue.text + displayValue.suffix : displayValue.text;
return (
<div className={styles.valueCell}>
<span className={styles.valueNumber}>{formattedValue}</span>
<span className={styles.percentNumber}>{row.original.totalPercent.toFixed(2)}%</span>
</div>
);
},
sortType: 'basic',
minWidth: 120,
width: 120,
});
return cols;
}
}, [data, onSymbolClick, colorScheme, theme, styles, focusedNode, callersNode, callersNodeLabel]);
}, [data, onSymbolClick, colorScheme, theme, styles, focusedNode, callersNode, callersNodeLabel, compact]);
// toggleRowExpanded is used in the Cell renderers but doesn't need to be in the dependencies
// because it's accessed at render time, not definition time
@@ -541,56 +555,38 @@ const FlameGraphCallTreeContainer = memo(
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.toolbarLeft}>
<div className={styles.searchContainer}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.currentTarget.value)}
placeholder="Search..."
className={styles.searchInput}
suffix={
searchQuery && (
{searchQuery && (
<div className={styles.searchContainer}>
{searchNodes.length > 0 && (
<div className={styles.searchNavigation}>
<span className={styles.searchCounter}>
{currentMatchIndex + 1} of {searchNodes.length}
{searchNodes.length >= 50 && '+'}
</span>
<Button
icon="times"
icon="angle-up"
fill="text"
size="sm"
onClick={clearSearch}
tooltip="Clear search"
aria-label="Clear search"
onClick={navigateToPrevMatch}
tooltip="Previous match"
aria-label="Previous match"
/>
)
}
/>
{searchNodes.length > 0 && (
<div className={styles.searchNavigation}>
<span className={styles.searchCounter}>
{currentMatchIndex + 1} of {searchNodes.length}
{searchNodes.length >= 50 && '+'}
</span>
<Button
icon="angle-up"
fill="text"
size="sm"
onClick={navigateToPrevMatch}
tooltip="Previous match"
aria-label="Previous match"
/>
<Button
icon="angle-down"
fill="text"
size="sm"
onClick={navigateToNextMatch}
tooltip="Next match"
aria-label="Next match"
/>
</div>
)}
{searchQuery && searchNodes.length === 0 && !searchError && (
<span className={styles.searchNoResults}>No matches found</span>
)}
{searchError && (
<span className={styles.searchError}>{searchError}</span>
)}
</div>
<Button
icon="angle-down"
fill="text"
size="sm"
onClick={navigateToNextMatch}
tooltip="Next match"
aria-label="Next match"
/>
</div>
)}
{searchQuery && searchNodes.length === 0 && !searchError && (
<span className={styles.searchNoResults}>No matches found</span>
)}
{searchError && <span className={styles.searchError}>{searchError}</span>}
</div>
)}
</div>
{focusedNode && (
@@ -848,6 +844,7 @@ function FunctionCellWithExpander({
onSymbolClick,
styles,
allNodes,
compact = false,
}: {
row: Row<CallTreeNode> & UseExpandedRowProps<CallTreeNode>;
value: string;
@@ -858,6 +855,7 @@ function FunctionCellWithExpander({
onSymbolClick: (symbol: string) => void;
styles: any;
allNodes: CallTreeNode[];
compact?: boolean;
}) {
const handleClick = () => {
if (hasChildren) {
@@ -968,7 +966,7 @@ function FunctionCellWithExpander({
<Button fill="text" size="sm" onClick={handleClick} className={styles.functionButton}>
{value}
</Button>
{row.original.childCount > 0 && (
{!compact && row.original.childCount > 0 && (
<span className={styles.nodeBadge}>
{row.original.childCount} {row.original.childCount === 1 ? 'child' : 'children'}, {row.original.subtreeSize} {row.original.subtreeSize === 1 ? 'node' : 'nodes'}
</span>
@@ -1077,9 +1075,9 @@ function getStyles(theme: GrafanaTheme2) {
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
toolbarLeft: css({
flexGrow: 1,
minWidth: '150px',
maxWidth: '350px',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
toolbarRight: css({
display: 'flex',
@@ -1087,14 +1085,10 @@ function getStyles(theme: GrafanaTheme2) {
flexWrap: 'wrap',
gap: theme.spacing(1),
}),
searchInput: css({
width: '100%',
}),
searchContainer: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
width: '100%',
flexWrap: 'wrap',
}),
searchNavigation: css({
@@ -43,10 +43,13 @@ describe('FlameGraph', () => {
setRangeMax={setRangeMax}
onItemFocused={onItemFocused}
textAlign={'left'}
onTextAlignChange={jest.fn()}
onSandwich={onSandwich}
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
colorScheme={ColorScheme.ValueBased}
onColorSchemeChange={jest.fn()}
isDiffMode={false}
selectedView={SelectedView.FlameGraph}
search={''}
collapsedMap={container.getCollapsedMap()}
@@ -19,8 +19,10 @@
import { css, cx } from '@emotion/css';
import { useEffect, useState } from 'react';
import { Icon } from '@grafana/ui';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, ButtonGroup, Dropdown, Icon, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './colors';
import { PIXELS_PER_LEVEL } from '../constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types';
@@ -39,11 +41,14 @@ type Props = {
onItemFocused: (data: ClickedItemData) => void;
focusedItemData?: ClickedItemData;
textAlign: TextAlign;
onTextAlignChange: (align: TextAlign) => void;
sandwichItem?: string;
onSandwich: (label: string) => void;
onFocusPillClick: () => void;
onSandwichPillClick: () => void;
colorScheme: ColorScheme | ColorSchemeDiff;
onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
isDiffMode: boolean;
showFlameGraphOnly?: boolean;
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
collapsing?: boolean;
@@ -63,11 +68,14 @@ const FlameGraph = ({
onItemFocused,
focusedItemData,
textAlign,
onTextAlignChange,
onSandwich,
sandwichItem,
onFocusPillClick,
onSandwichPillClick,
colorScheme,
onColorSchemeChange,
isDiffMode,
showFlameGraphOnly,
getExtraContextMenuButtons,
collapsing,
@@ -76,7 +84,7 @@ const FlameGraph = ({
collapsedMap,
setCollapsedMap,
}: Props) => {
const styles = getStyles();
const styles = useStyles2(getStyles);
const [levels, setLevels] = useState<LevelItem[][]>();
const [levelsCallers, setLevelsCallers] = useState<LevelItem[][]>();
@@ -175,28 +183,183 @@ const FlameGraph = ({
);
}
const alignOptions: Array<SelectableValue<TextAlign>> = [
{ value: 'left', description: 'Align text left', icon: 'align-left' },
{ value: 'right', description: 'Align text right', icon: 'align-right' },
];
return (
<div className={styles.graph}>
<FlameGraphMetadata
data={data}
focusedItem={focusedItemData}
sandwichedLabel={sandwichItem}
totalTicks={totalViewTicks}
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
/>
<div className={styles.toolbar}>
<FlameGraphMetadata
data={data}
focusedItem={focusedItemData}
sandwichedLabel={sandwichItem}
totalTicks={totalViewTicks}
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
/>
<div className={styles.controls}>
<ColorSchemeButton value={colorScheme} onChange={onColorSchemeChange} isDiffMode={isDiffMode} />
<ButtonGroup className={styles.buttonSpacing}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Expand all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(false));
}}
aria-label={'Expand all groups'}
icon={'angle-double-down'}
/>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Collapse all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(true));
}}
aria-label={'Collapse all groups'}
icon={'angle-double-up'}
/>
</ButtonGroup>
<RadioButtonGroup<TextAlign>
size="sm"
options={alignOptions}
value={textAlign}
onChange={onTextAlignChange}
/>
</div>
</div>
{canvas}
</div>
);
};
const getStyles = () => ({
type ColorSchemeButtonProps = {
value: ColorScheme | ColorSchemeDiff;
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
isDiffMode: boolean;
};
function ColorSchemeButton(props: ColorSchemeButtonProps) {
const styles = useStyles2(getStyles);
let menu = (
<Menu>
<Menu.Item label="By package name" onClick={() => props.onChange(ColorScheme.PackageBased)} />
<Menu.Item label="By value" onClick={() => props.onChange(ColorScheme.ValueBased)} />
</Menu>
);
// Show a bit different gradient as a way to indicate selected value
const colorDotStyle =
{
[ColorScheme.ValueBased]: styles.colorDotByValue,
[ColorScheme.PackageBased]: styles.colorDotByPackage,
[ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind,
[ColorSchemeDiff.Default]: styles.colorDotDiffDefault,
}[props.value] || styles.colorDotByValue;
let contents = <span className={cx(styles.colorDot, colorDotStyle)} />;
if (props.isDiffMode) {
menu = (
<Menu>
<Menu.Item label="Default (green to red)" onClick={() => props.onChange(ColorSchemeDiff.Default)} />
<Menu.Item label="Color blind (blue to red)" onClick={() => props.onChange(ColorSchemeDiff.DiffColorBlind)} />
</Menu>
);
contents = (
<div className={cx(styles.colorDotDiff, colorDotStyle)}>
<div>-100% (removed)</div>
<div>0%</div>
<div>+100% (added)</div>
</div>
);
}
return (
<Dropdown overlay={menu}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Change color scheme'}
onClick={() => {}}
className={styles.buttonSpacing}
aria-label={'Change color scheme'}
>
{contents}
</Button>
</Dropdown>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
graph: css({
label: 'graph',
overflow: 'auto',
flexGrow: 1,
flexBasis: '50%',
}),
toolbar: css({
label: 'toolbar',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: theme.spacing(1),
}),
controls: css({
label: 'controls',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
buttonSpacing: css({
label: 'buttonSpacing',
marginRight: theme.spacing(1),
}),
colorDot: css({
label: 'colorDot',
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: theme.shape.radius.circle,
}),
colorDotDiff: css({
label: 'colorDotDiff',
display: 'flex',
width: '200px',
height: '12px',
color: 'white',
fontSize: 9,
lineHeight: 1.3,
fontWeight: 300,
justifyContent: 'space-between',
padding: '0 2px',
// We have a specific sizing for this so probably makes sense to use hardcoded value here
// eslint-disable-next-line @grafana/no-border-radius-literal
borderRadius: '2px',
}),
colorDotByValue: css({
label: 'colorDotByValue',
background: byValueGradient,
}),
colorDotByPackage: css({
label: 'colorDotByPackage',
background: byPackageGradient,
}),
colorDotDiffDefault: css({
label: 'colorDotDiffDefault',
background: diffDefaultGradient,
}),
colorDotDiffColorBlind: css({
label: 'colorDotDiffColorBlind',
background: diffColorBlindGradient,
}),
sandwichCanvasWrapper: css({
label: 'sandwichCanvasWrapper',
display: 'flex',
@@ -7,13 +7,14 @@ import { useMeasure } from 'react-use';
import { DataFrame, GrafanaTheme2, escapeStringForRegex } from '@grafana/data';
import { ThemeContext } from '@grafana/ui';
import FlameGraphCallTreeContainer from './CallTree/FlameGraphCallTreeContainer';
import FlameGraph from './FlameGraph/FlameGraph';
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
import { CollapsedMap, FlameGraphDataContainer } from './FlameGraph/dataTransform';
import FlameGraphHeader from './FlameGraphHeader';
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, PaneView, SelectedView, TextAlign, ViewMode } from './types';
import { getAssistantContextFromDataFrame } from './utils';
const ufuzzy = new uFuzzy();
@@ -110,6 +111,10 @@ const FlameGraphContainer = ({
const [rangeMax, setRangeMax] = useState(1);
const [search, setSearch] = useState('');
const [selectedView, setSelectedView] = useState(SelectedView.Both);
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Split);
const [leftPaneView, setLeftPaneView] = useState<PaneView>(PaneView.TopTable);
const [rightPaneView, setRightPaneView] = useState<PaneView>(PaneView.FlameGraph);
const [singleView, setSingleView] = useState<PaneView>(PaneView.FlameGraph);
const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
const [textAlign, setTextAlign] = useState<TextAlign>('left');
// This is a label of the item because in sandwich view we group all items by label and present a merged graph
@@ -217,6 +222,10 @@ const FlameGraphContainer = ({
onItemFocused={(data) => setFocusedItemData(data)}
focusedItemData={focusedItemData}
textAlign={textAlign}
onTextAlignChange={(align) => {
setTextAlign(align);
onTextAlignSelected?.(align);
}}
sandwichItem={sandwichItem}
onSandwich={(label: string) => {
resetFocus();
@@ -225,6 +234,8 @@ const FlameGraphContainer = ({
onFocusPillClick={resetFocus}
onSandwichPillClick={resetSandwich}
colorScheme={colorScheme}
onColorSchemeChange={setColorScheme}
isDiffMode={dataContainer.isDiffFlamegraph()}
showFlameGraphOnly={showFlameGraphOnly}
collapsing={!disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
@@ -255,26 +266,69 @@ const FlameGraphContainer = ({
/>
);
// Use compact mode for CallTree when in split view
const isCallTreeInSplitView =
selectedView === SelectedView.Both &&
viewMode === ViewMode.Split &&
(leftPaneView === PaneView.CallTree || rightPaneView === PaneView.CallTree);
const callTree = (
<FlameGraphCallTreeContainer
data={dataContainer}
onSymbolClick={onSymbolClick}
sandwichItem={sandwichItem}
onSandwich={setSandwichItem}
onTableSort={onTableSort}
colorScheme={colorScheme}
search={search}
compact={isCallTreeInSplitView}
/>
);
// Helper function to render a pane based on its view type
const renderPane = (paneView: PaneView) => {
switch (paneView) {
case PaneView.TopTable:
// TopTable uses AutoSizer which needs a parent with defined height
return <div className={styles.tableContainer}>{table}</div>;
case PaneView.FlameGraph:
return flameGraph;
case PaneView.CallTree:
// CallTree also uses AutoSizer which needs a parent with defined height
return <div className={styles.tableContainer}>{callTree}</div>;
default:
return flameGraph;
}
};
let body;
if (showFlameGraphOnly || selectedView === SelectedView.FlameGraph) {
body = flameGraph;
} else if (selectedView === SelectedView.TopTable) {
body = <div className={styles.tableContainer}>{table}</div>;
} else if (selectedView === SelectedView.CallTree) {
body = <div className={styles.tableContainer}>{callTree}</div>;
} else if (selectedView === SelectedView.Both) {
if (vertical) {
body = (
<div>
<div className={styles.verticalGraphContainer}>{flameGraph}</div>
<div className={styles.verticalTableContainer}>{table}</div>
</div>
);
// New view model: support split view with independent pane selections
if (viewMode === ViewMode.Split) {
if (vertical) {
body = (
<div>
<div className={styles.verticalPaneContainer}>{renderPane(leftPaneView)}</div>
<div className={styles.verticalPaneContainer}>{renderPane(rightPaneView)}</div>
</div>
);
} else {
body = (
<div className={styles.horizontalContainer}>
<div className={styles.horizontalPaneContainer}>{renderPane(leftPaneView)}</div>
<div className={styles.horizontalPaneContainer}>{renderPane(rightPaneView)}</div>
</div>
);
}
} else {
body = (
<div className={styles.horizontalContainer}>
<div className={styles.horizontalTableContainer}>{table}</div>
<div className={styles.horizontalGraphContainer}>{flameGraph}</div>
</div>
);
// Single view mode
body = <div className={styles.singlePaneContainer}>{renderPane(singleView)}</div>;
}
}
@@ -292,25 +346,23 @@ const FlameGraphContainer = ({
setSelectedView(view);
onViewSelected?.(view);
}}
viewMode={viewMode}
setViewMode={setViewMode}
leftPaneView={leftPaneView}
setLeftPaneView={setLeftPaneView}
rightPaneView={rightPaneView}
setRightPaneView={setRightPaneView}
singleView={singleView}
setSingleView={setSingleView}
containerWidth={containerWidth}
onReset={() => {
resetFocus();
resetSandwich();
}}
textAlign={textAlign}
onTextAlignChange={(align) => {
setTextAlign(align);
onTextAlignSelected?.(align);
}}
showResetButton={Boolean(focusedItemData || sandwichItem)}
colorScheme={colorScheme}
onColorSchemeChange={setColorScheme}
stickyHeader={Boolean(stickyHeader)}
extraHeaderElements={extraHeaderElements}
vertical={vertical}
isDiffMode={dataContainer.isDiffFlamegraph()}
setCollapsedMap={setCollapsedMap}
collapsedMap={collapsedMap}
assistantContext={data && showAnalyzeWithAssistant ? getAssistantContextFromDataFrame(data) : undefined}
/>
)}
@@ -435,20 +487,20 @@ function getStyles(theme: GrafanaTheme2) {
width: '100%',
}),
horizontalGraphContainer: css({
flexBasis: '50%',
}),
horizontalTableContainer: css({
horizontalPaneContainer: css({
label: 'horizontalPaneContainer',
flexBasis: '50%',
maxHeight: 800,
}),
verticalGraphContainer: css({
verticalPaneContainer: css({
label: 'verticalPaneContainer',
marginBottom: theme.spacing(1),
height: 800,
}),
verticalTableContainer: css({
singlePaneContainer: css({
label: 'singlePaneContainer',
height: 800,
}),
};
@@ -3,9 +3,8 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { CollapsedMap } from './FlameGraph/dataTransform';
import FlameGraphHeader from './FlameGraphHeader';
import { ColorScheme, SelectedView } from './types';
import { PaneView, SelectedView, ViewMode } from './types';
jest.mock('@grafana/assistant', () => ({
useAssistant: jest.fn().mockReturnValue({
@@ -20,8 +19,11 @@ describe('FlameGraphHeader', () => {
function setup(props: Partial<React.ComponentProps<typeof FlameGraphHeader>> = {}) {
const setSearch = jest.fn();
const setSelectedView = jest.fn();
const setViewMode = jest.fn();
const setLeftPaneView = jest.fn();
const setRightPaneView = jest.fn();
const setSingleView = jest.fn();
const onReset = jest.fn();
const onSchemeChange = jest.fn();
const renderResult = render(
<FlameGraphHeader
@@ -29,17 +31,18 @@ describe('FlameGraphHeader', () => {
setSearch={setSearch}
selectedView={SelectedView.Both}
setSelectedView={setSelectedView}
viewMode={ViewMode.Split}
setViewMode={setViewMode}
leftPaneView={PaneView.TopTable}
setLeftPaneView={setLeftPaneView}
rightPaneView={PaneView.FlameGraph}
setRightPaneView={setRightPaneView}
singleView={PaneView.FlameGraph}
setSingleView={setSingleView}
containerWidth={1600}
onReset={onReset}
onTextAlignChange={jest.fn()}
textAlign={'left'}
showResetButton={true}
colorScheme={ColorScheme.ValueBased}
onColorSchemeChange={onSchemeChange}
stickyHeader={false}
isDiffMode={false}
setCollapsedMap={() => {}}
collapsedMap={new CollapsedMap()}
{...props}
/>
);
@@ -50,7 +53,6 @@ describe('FlameGraphHeader', () => {
setSearch,
setSelectedView,
onReset,
onSchemeChange,
},
};
}
@@ -70,27 +72,4 @@ describe('FlameGraphHeader', () => {
await userEvent.click(resetButton);
expect(handlers.onReset).toHaveBeenCalledTimes(1);
});
it('calls on color scheme change when clicked', async () => {
const { handlers } = setup();
const changeButton = screen.getByLabelText(/Change color scheme/);
expect(changeButton).toBeInTheDocument();
await userEvent.click(changeButton);
const byPackageButton = screen.getByText(/By package name/);
expect(byPackageButton).toBeInTheDocument();
await userEvent.click(byPackageButton);
expect(handlers.onSchemeChange).toHaveBeenCalledTimes(1);
});
it('shows diff color scheme switch when diff', async () => {
setup({ isDiffMode: true });
const changeButton = screen.getByLabelText(/Change color scheme/);
expect(changeButton).toBeInTheDocument();
await userEvent.click(changeButton);
expect(screen.getByText(/Default/)).toBeInTheDocument();
expect(screen.getByText(/Color blind/)).toBeInTheDocument();
});
});
@@ -5,30 +5,29 @@ import { useDebounce, usePrevious } from 'react-use';
import { ChatContextItem, OpenAssistantButton } from '@grafana/assistant';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, ButtonGroup, Dropdown, Input, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { Button, Input, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './FlameGraph/colors';
import { CollapsedMap } from './FlameGraph/dataTransform';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
import { ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types';
import { PaneView, SelectedView, ViewMode } from './types';
type Props = {
search: string;
setSearch: (search: string) => void;
selectedView: SelectedView;
setSelectedView: (view: SelectedView) => void;
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
leftPaneView: PaneView;
setLeftPaneView: (view: PaneView) => void;
rightPaneView: PaneView;
setRightPaneView: (view: PaneView) => void;
singleView: PaneView;
setSingleView: (view: PaneView) => void;
containerWidth: number;
onReset: () => void;
textAlign: TextAlign;
onTextAlignChange: (align: TextAlign) => void;
showResetButton: boolean;
colorScheme: ColorScheme | ColorSchemeDiff;
onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
stickyHeader: boolean;
vertical?: boolean;
isDiffMode: boolean;
setCollapsedMap: (collapsedMap: CollapsedMap) => void;
collapsedMap: CollapsedMap;
extraHeaderElements?: React.ReactNode;
@@ -40,19 +39,20 @@ const FlameGraphHeader = ({
setSearch,
selectedView,
setSelectedView,
viewMode,
setViewMode,
leftPaneView,
setLeftPaneView,
rightPaneView,
setRightPaneView,
singleView,
setSingleView,
containerWidth,
onReset,
textAlign,
onTextAlignChange,
showResetButton,
colorScheme,
onColorSchemeChange,
stickyHeader,
extraHeaderElements,
vertical,
isDiffMode,
setCollapsedMap,
collapsedMap,
assistantContext,
}: Props) => {
const styles = useStyles2(getStyles);
@@ -87,6 +87,25 @@ const FlameGraphHeader = ({
/>
</div>
{selectedView === SelectedView.Both && viewMode === ViewMode.Split && (
<div className={styles.middleContainer}>
<RadioButtonGroup<PaneView>
size="sm"
options={paneViewOptions}
value={leftPaneView}
onChange={setLeftPaneView}
className={styles.buttonSpacing}
/>
<RadioButtonGroup<PaneView>
size="sm"
options={paneViewOptions}
value={rightPaneView}
onChange={setRightPaneView}
className={styles.buttonSpacing}
/>
</div>
)}
<div className={styles.rightContainer}>
{!!assistantContext?.length && (
<div className={styles.buttonSpacing}>
@@ -111,129 +130,62 @@ const FlameGraphHeader = ({
aria-label={'Reset focus and sandwich state'}
/>
)}
<ColorSchemeButton value={colorScheme} onChange={onColorSchemeChange} isDiffMode={isDiffMode} />
<ButtonGroup className={styles.buttonSpacing}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Expand all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(false));
}}
aria-label={'Expand all groups'}
icon={'angle-double-down'}
disabled={selectedView === SelectedView.TopTable}
{selectedView === SelectedView.Both ? (
<>
<RadioButtonGroup<ViewMode>
size="sm"
options={viewModeOptions}
value={viewMode}
onChange={setViewMode}
className={styles.buttonSpacing}
/>
{viewMode === ViewMode.Single && (
<RadioButtonGroup<PaneView>
size="sm"
options={paneViewOptions}
value={singleView}
onChange={setSingleView}
className={styles.buttonSpacing}
/>
)}
</>
) : (
<RadioButtonGroup<SelectedView>
size="sm"
options={getViewOptions(containerWidth, vertical)}
value={selectedView}
onChange={setSelectedView}
/>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Collapse all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(true));
}}
aria-label={'Collapse all groups'}
icon={'angle-double-up'}
disabled={selectedView === SelectedView.TopTable}
/>
</ButtonGroup>
<RadioButtonGroup<TextAlign>
size="sm"
disabled={selectedView === SelectedView.TopTable}
options={alignOptions}
value={textAlign}
onChange={onTextAlignChange}
className={styles.buttonSpacing}
/>
<RadioButtonGroup<SelectedView>
size="sm"
options={getViewOptions(containerWidth, vertical)}
value={selectedView}
onChange={setSelectedView}
/>
)}
{extraHeaderElements && <div className={styles.extraElements}>{extraHeaderElements}</div>}
</div>
</div>
);
};
type ColorSchemeButtonProps = {
value: ColorScheme | ColorSchemeDiff;
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
isDiffMode: boolean;
};
function ColorSchemeButton(props: ColorSchemeButtonProps) {
// TODO: probably create separate getStyles
const styles = useStyles2(getStyles);
let menu = (
<Menu>
<Menu.Item label="By package name" onClick={() => props.onChange(ColorScheme.PackageBased)} />
<Menu.Item label="By value" onClick={() => props.onChange(ColorScheme.ValueBased)} />
</Menu>
);
const viewModeOptions: Array<SelectableValue<ViewMode>> = [
{ value: ViewMode.Single, label: 'Single', description: 'Single view' },
{ value: ViewMode.Split, label: 'Split', description: 'Split view' },
];
// Show a bit different gradient as a way to indicate selected value
const colorDotStyle =
{
[ColorScheme.ValueBased]: styles.colorDotByValue,
[ColorScheme.PackageBased]: styles.colorDotByPackage,
[ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind,
[ColorSchemeDiff.Default]: styles.colorDotDiffDefault,
}[props.value] || styles.colorDotByValue;
let contents = <span className={cx(styles.colorDot, colorDotStyle)} />;
if (props.isDiffMode) {
menu = (
<Menu>
<Menu.Item label="Default (green to red)" onClick={() => props.onChange(ColorSchemeDiff.Default)} />
<Menu.Item label="Color blind (blue to red)" onClick={() => props.onChange(ColorSchemeDiff.DiffColorBlind)} />
</Menu>
);
contents = (
<div className={cx(styles.colorDotDiff, colorDotStyle)}>
<div>-100% (removed)</div>
<div>0%</div>
<div>+100% (added)</div>
</div>
);
}
return (
<Dropdown overlay={menu}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Change color scheme'}
onClick={() => {}}
className={styles.buttonSpacing}
aria-label={'Change color scheme'}
>
{contents}
</Button>
</Dropdown>
);
}
const alignOptions: Array<SelectableValue<TextAlign>> = [
{ value: 'left', description: 'Align text left', icon: 'align-left' },
{ value: 'right', description: 'Align text right', icon: 'align-right' },
const paneViewOptions: Array<SelectableValue<PaneView>> = [
{ value: PaneView.TopTable, label: 'Table' },
{ value: PaneView.FlameGraph, label: 'Flame' },
{ value: PaneView.CallTree, label: 'Tree' },
];
function getViewOptions(width: number, vertical?: boolean): Array<SelectableValue<SelectedView>> {
let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [
{ value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' },
{ value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' },
{ value: SelectedView.CallTree, label: 'Call Tree', description: 'Only show call tree' },
];
if (width >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH || vertical) {
viewOptions.push({
value: SelectedView.Both,
label: 'Both',
description: 'Show both the top table and flame graph',
description: 'Show split or single view with multiple visualizations',
});
}
@@ -273,10 +225,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'flex-start',
width: '100%',
top: 0,
gap: theme.spacing(1),
marginTop: theme.spacing(1),
position: 'relative',
}),
stickyHeader: css({
zIndex: theme.zIndex.navbarFixed,
@@ -285,10 +239,20 @@ const getStyles = (theme: GrafanaTheme2) => ({
}),
inputContainer: css({
label: 'inputContainer',
flexGrow: 1,
flexGrow: 0,
minWidth: '150px',
maxWidth: '350px',
}),
middleContainer: css({
label: 'middleContainer',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: theme.spacing(1),
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
}),
rightContainer: css({
label: 'rightContainer',
display: 'flex',
@@ -309,44 +273,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
padding: '0 5px',
color: theme.colors.text.disabled,
}),
colorDot: css({
label: 'colorDot',
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: theme.shape.radius.circle,
}),
colorDotDiff: css({
label: 'colorDotDiff',
display: 'flex',
width: '200px',
height: '12px',
color: 'white',
fontSize: 9,
lineHeight: 1.3,
fontWeight: 300,
justifyContent: 'space-between',
padding: '0 2px',
// We have a specific sizing for this so probably makes sense to use hardcoded value here
// eslint-disable-next-line @grafana/no-border-radius-literal
borderRadius: '2px',
}),
colorDotByValue: css({
label: 'colorDotByValue',
background: byValueGradient,
}),
colorDotByPackage: css({
label: 'colorDotByPackage',
background: byPackageGradient,
}),
colorDotDiffDefault: css({
label: 'colorDotDiffDefault',
background: diffDefaultGradient,
}),
colorDotDiffColorBlind: css({
label: 'colorDotDiffColorBlind',
background: diffColorBlindGradient,
}),
extraElements: css({
label: 'extraElements',
marginLeft: theme.spacing(1),
+1
View File
@@ -1,3 +1,4 @@
export { default as FlameGraph, type Props } from './FlameGraphContainer';
export { default as FlameGraphCallTreeContainer } from './CallTree/FlameGraphCallTreeContainer';
export { checkFields, getMessageCheckFieldsResult } from './FlameGraph/dataTransform';
export { data } from './FlameGraph/testData/dataNestedSet';
+12
View File
@@ -21,6 +21,18 @@ export enum SelectedView {
TopTable = 'topTable',
FlameGraph = 'flameGraph',
Both = 'both',
CallTree = 'callTree',
}
export enum ViewMode {
Single = 'single',
Split = 'split',
}
export enum PaneView {
TopTable = 'topTable',
FlameGraph = 'flameGraph',
CallTree = 'callTree',
}
export interface TableData {
+4 -2
View File
@@ -3507,6 +3507,7 @@ __metadata:
"@types/lodash": "npm:4.17.20"
"@types/node": "npm:24.10.1"
"@types/react": "npm:18.3.18"
"@types/react-table": "npm:^7.7.20"
"@types/react-virtualized-auto-sizer": "npm:1.0.8"
"@types/tinycolor2": "npm:1.4.6"
babel-jest: "npm:29.7.0"
@@ -3517,6 +3518,7 @@ __metadata:
jest-canvas-mock: "npm:2.5.2"
lodash: "npm:4.17.21"
react: "npm:18.3.1"
react-table: "npm:^7.8.0"
react-use: "npm:17.6.0"
react-virtualized-auto-sizer: "npm:1.0.26"
rollup: "npm:^4.22.4"
@@ -11159,7 +11161,7 @@ __metadata:
languageName: node
linkType: hard
"@types/react-table@npm:7.7.20":
"@types/react-table@npm:7.7.20, @types/react-table@npm:^7.7.20":
version: 7.7.20
resolution: "@types/react-table@npm:7.7.20"
dependencies:
@@ -29371,7 +29373,7 @@ __metadata:
languageName: node
linkType: hard
"react-table@npm:7.8.0":
"react-table@npm:7.8.0, react-table@npm:^7.8.0":
version: 7.8.0
resolution: "react-table@npm:7.8.0"
peerDependencies: