From 17817bdda79c58b27b5486f232414441d2d5f210 Mon Sep 17 00:00:00 2001 From: Aleksandar Petrov <8142643+aleks-p@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:05:59 -0400 Subject: [PATCH] Add call tree to flame graph container --- packages/grafana-flamegraph/package.json | 2 + .../FlameGraphCallTreeContainer.story.tsx | 1 + .../CallTree/FlameGraphCallTreeContainer.tsx | 224 ++++++++-------- .../src/FlameGraph/FlameGraph.test.tsx | 3 + .../src/FlameGraph/FlameGraph.tsx | 185 ++++++++++++- .../src/FlameGraphContainer.tsx | 114 +++++--- .../src/FlameGraphHeader.test.tsx | 47 +--- .../src/FlameGraphHeader.tsx | 246 ++++++------------ packages/grafana-flamegraph/src/index.ts | 1 + packages/grafana-flamegraph/src/types.ts | 12 + yarn.lock | 6 +- 11 files changed, 488 insertions(+), 353 deletions(-) diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index b1c16ed68d1..16a39a07f5d 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -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", diff --git a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx index 88eb4d898ad..ceac052f409 100644 --- a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx +++ b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx @@ -13,6 +13,7 @@ const meta: Meta = { component: FlameGraphCallTreeContainer, args: { colorScheme: ColorScheme.PackageBased, + search: '', }, decorators: [ (Story) => ( diff --git a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx index f20070efc2b..7ca015152e2 100644 --- a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx +++ b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx @@ -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(undefined); - // Search state - const [searchQuery, setSearchQuery] = useState(''); + // Search state - use search from parent (shared with TopTable and FlameGraph) + const searchQuery = search; const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [searchError, setSearchError] = useState(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[]>(() => { if (data.isDiffFlamegraph()) { - return [ + const cols: Column[] = [ { 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 }) => ( @@ -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[] = [ { 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 }) => ( - - ), - minWidth: 200, - width: 200, - disableSortBy: true, - }, - { - Header: 'Self', - accessor: 'self', - Cell: ({ row }: { row: Row }) => { - const displaySelf = data.getSelfDisplay([row.original.levelItem.itemIndexes[0]]); - const formattedValue = displaySelf.suffix ? displaySelf.text + displaySelf.suffix : displaySelf.text; - return ( -
- {formattedValue} - {row.original.selfPercent.toFixed(2)}% -
- ); - }, - sortType: 'basic', - minWidth: 120, - width: 120, - }, - { - Header: 'Total', - accessor: 'total', - Cell: ({ row }: { row: Row }) => { - const displayValue = data.valueDisplayProcessor(row.original.total); - const formattedValue = displayValue.suffix ? displayValue.text + displayValue.suffix : displayValue.text; - return ( -
- {formattedValue} - {row.original.totalPercent.toFixed(2)}% -
- ); - }, - sortType: 'basic', - minWidth: 120, - width: 120, - }, ]; + + if (!compact) { + cols.push( + { + Header: '', + id: 'colorBar', + Cell: ({ row }: { row: Row }) => ( + + ), + minWidth: 200, + width: 200, + disableSortBy: true, + }, + { + Header: 'Self', + accessor: 'self', + Cell: ({ row }: { row: Row }) => { + const displaySelf = data.getSelfDisplay([row.original.levelItem.itemIndexes[0]]); + const formattedValue = displaySelf.suffix ? displaySelf.text + displaySelf.suffix : displaySelf.text; + return ( +
+ {formattedValue} + {row.original.selfPercent.toFixed(2)}% +
+ ); + }, + sortType: 'basic', + minWidth: 120, + width: 120, + } + ); + } + + cols.push({ + Header: 'Total', + accessor: 'total', + Cell: ({ row }: { row: Row }) => { + const displayValue = data.valueDisplayProcessor(row.original.total); + const formattedValue = displayValue.suffix ? displayValue.text + displayValue.suffix : displayValue.text; + return ( +
+ {formattedValue} + {row.original.totalPercent.toFixed(2)}% +
+ ); + }, + 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 */}
-
- setSearchQuery(e.currentTarget.value)} - placeholder="Search..." - className={styles.searchInput} - suffix={ - searchQuery && ( + {searchQuery && ( +
+ {searchNodes.length > 0 && ( +
+ + {currentMatchIndex + 1} of {searchNodes.length} + {searchNodes.length >= 50 && '+'} +
- )} - {searchQuery && searchNodes.length === 0 && !searchError && ( - No matches found - )} - {searchError && ( - {searchError} - )} -
+
+ )} + {searchQuery && searchNodes.length === 0 && !searchError && ( + No matches found + )} + {searchError && {searchError}} +
+ )}
{focusedNode && ( @@ -848,6 +844,7 @@ function FunctionCellWithExpander({ onSymbolClick, styles, allNodes, + compact = false, }: { row: Row & UseExpandedRowProps; 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({ - {row.original.childCount > 0 && ( + {!compact && row.original.childCount > 0 && ( {row.original.childCount} {row.original.childCount === 1 ? 'child' : 'children'}, {row.original.subtreeSize} {row.original.subtreeSize === 1 ? 'node' : 'nodes'} @@ -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({ diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx index b79074cfc6b..acddc0dee52 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx @@ -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()} diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx index fbe14d1d281..24e194d5834 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx @@ -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(); const [levelsCallers, setLevelsCallers] = useState(); @@ -175,28 +183,183 @@ const FlameGraph = ({ ); } + const alignOptions: Array> = [ + { value: 'left', description: 'Align text left', icon: 'align-left' }, + { value: 'right', description: 'Align text right', icon: 'align-right' }, + ]; + return (
- +
+ +
+ + +
+
{canvas}
); }; -const getStyles = () => ({ +type ColorSchemeButtonProps = { + value: ColorScheme | ColorSchemeDiff; + onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void; + isDiffMode: boolean; +}; + +function ColorSchemeButton(props: ColorSchemeButtonProps) { + const styles = useStyles2(getStyles); + let menu = ( + + props.onChange(ColorScheme.PackageBased)} /> + props.onChange(ColorScheme.ValueBased)} /> + + ); + + // 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 = ; + + if (props.isDiffMode) { + menu = ( + + props.onChange(ColorSchemeDiff.Default)} /> + props.onChange(ColorSchemeDiff.DiffColorBlind)} /> + + ); + + contents = ( +
+
-100% (removed)
+
0%
+
+100% (added)
+
+ ); + } + + return ( + + + + ); +} + +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', diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx index 5d95051e353..e7fd36c64ff 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -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.Split); + const [leftPaneView, setLeftPaneView] = useState(PaneView.TopTable); + const [rightPaneView, setRightPaneView] = useState(PaneView.FlameGraph); + const [singleView, setSingleView] = useState(PaneView.FlameGraph); const [sizeRef, { width: containerWidth }] = useMeasure(); const [textAlign, setTextAlign] = useState('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 = ( + + ); + + // 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
{table}
; + case PaneView.FlameGraph: + return flameGraph; + case PaneView.CallTree: + // CallTree also uses AutoSizer which needs a parent with defined height + return
{callTree}
; + default: + return flameGraph; + } + }; + let body; if (showFlameGraphOnly || selectedView === SelectedView.FlameGraph) { body = flameGraph; } else if (selectedView === SelectedView.TopTable) { body =
{table}
; + } else if (selectedView === SelectedView.CallTree) { + body =
{callTree}
; } else if (selectedView === SelectedView.Both) { - if (vertical) { - body = ( -
-
{flameGraph}
-
{table}
-
- ); + // New view model: support split view with independent pane selections + if (viewMode === ViewMode.Split) { + if (vertical) { + body = ( +
+
{renderPane(leftPaneView)}
+
{renderPane(rightPaneView)}
+
+ ); + } else { + body = ( +
+
{renderPane(leftPaneView)}
+
{renderPane(rightPaneView)}
+
+ ); + } } else { - body = ( -
-
{table}
-
{flameGraph}
-
- ); + // Single view mode + body =
{renderPane(singleView)}
; } } @@ -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, }), }; diff --git a/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx b/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx index ce00964a8d5..0177839aafc 100644 --- a/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx @@ -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> = {}) { 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( { 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(); - }); }); diff --git a/packages/grafana-flamegraph/src/FlameGraphHeader.tsx b/packages/grafana-flamegraph/src/FlameGraphHeader.tsx index fbeb0ad3c6a..6684f55284f 100644 --- a/packages/grafana-flamegraph/src/FlameGraphHeader.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphHeader.tsx @@ -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 = ({ /> + {selectedView === SelectedView.Both && viewMode === ViewMode.Split && ( +
+ + size="sm" + options={paneViewOptions} + value={leftPaneView} + onChange={setLeftPaneView} + className={styles.buttonSpacing} + /> + + size="sm" + options={paneViewOptions} + value={rightPaneView} + onChange={setRightPaneView} + className={styles.buttonSpacing} + /> +
+ )} +
{!!assistantContext?.length && (
@@ -111,129 +130,62 @@ const FlameGraphHeader = ({ aria-label={'Reset focus and sandwich state'} /> )} - - -
); }; -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 = ( - - props.onChange(ColorScheme.PackageBased)} /> - props.onChange(ColorScheme.ValueBased)} /> - - ); +const viewModeOptions: Array> = [ + { 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 = ; - - if (props.isDiffMode) { - menu = ( - - props.onChange(ColorSchemeDiff.Default)} /> - props.onChange(ColorSchemeDiff.DiffColorBlind)} /> - - ); - - contents = ( -
-
-100% (removed)
-
0%
-
+100% (added)
-
- ); - } - - return ( - - - - ); -} - -const alignOptions: Array> = [ - { value: 'left', description: 'Align text left', icon: 'align-left' }, - { value: 'right', description: 'Align text right', icon: 'align-right' }, +const paneViewOptions: Array> = [ + { value: PaneView.TopTable, label: 'Table' }, + { value: PaneView.FlameGraph, label: 'Flame' }, + { value: PaneView.CallTree, label: 'Tree' }, ]; function getViewOptions(width: number, vertical?: boolean): Array> { 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), diff --git a/packages/grafana-flamegraph/src/index.ts b/packages/grafana-flamegraph/src/index.ts index ab6407c5e49..32558e56165 100644 --- a/packages/grafana-flamegraph/src/index.ts +++ b/packages/grafana-flamegraph/src/index.ts @@ -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'; diff --git a/packages/grafana-flamegraph/src/types.ts b/packages/grafana-flamegraph/src/types.ts index aa8a84de882..48f11fd9abd 100644 --- a/packages/grafana-flamegraph/src/types.ts +++ b/packages/grafana-flamegraph/src/types.ts @@ -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 { diff --git a/yarn.lock b/yarn.lock index 5559a4bed77..ad388aa13f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: