diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.story.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.story.tsx index fdd0cd08074..8613abb3996 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.story.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.story.tsx @@ -16,7 +16,7 @@ const meta: Meta = { rangeMax: 1, textAlign: 'left', colorScheme: ColorScheme.PackageBased, - selectedView: SelectedView.Both, + selectedView: SelectedView.Multi, search: '', }, }; diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx index 20cf606aee8..2cd869e1d2a 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -1,20 +1,18 @@ import { css } from '@emotion/css'; import uFuzzy from '@leeoniya/ufuzzy'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import * as React from 'react'; import { useMeasure } from 'react-use'; -import { DataFrame, GrafanaTheme2, escapeStringForRegex } from '@grafana/data'; +import { DataFrame, GrafanaTheme2 } from '@grafana/data'; import { ThemeContext } from '@grafana/ui'; -import FlameGraphCallTreeContainer from './CallTree/FlameGraphCallTreeContainer'; -import FlameGraph from './FlameGraph/FlameGraph'; +import { FlameGraphDataContainer } from './FlameGraph/dataTransform'; import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu'; -import { CollapsedMap, FlameGraphDataContainer } from './FlameGraph/dataTransform'; import FlameGraphHeader from './FlameGraphHeader'; -import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer'; +import FlameGraphPane from './FlameGraphPane'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; -import { ClickedItemData, ColorScheme, ColorSchemeDiff, PaneView, SelectedView, TextAlign, ViewMode } from './types'; +import { PaneView, SelectedView, ViewMode } from './types'; import { getAssistantContextFromDataFrame } from './utils'; const ufuzzy = new uFuzzy(); @@ -105,21 +103,18 @@ const FlameGraphContainer = ({ getExtraContextMenuButtons, showAnalyzeWithAssistant = true, }: Props) => { - const [focusedItemData, setFocusedItemData] = useState(); - - const [rangeMin, setRangeMin] = useState(0); - const [rangeMax, setRangeMax] = useState(1); + // Shared state across all views const [search, setSearch] = useState(''); - const [selectedView, setSelectedView] = useState(SelectedView.Both); + const [selectedView, setSelectedView] = useState(SelectedView.Multi); 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 - const [sandwichItem, setSandwichItem] = useState(); - const [collapsedMap, setCollapsedMap] = useState(new CollapsedMap()); + // Used to trigger reset of pane-specific state (focus, sandwich) when parent reset button is clicked + const [resetKey, setResetKey] = useState(0); + // Track if we temporarily switched away from Both view due to narrow width + const [viewBeforeNarrow, setViewBeforeNarrow] = useState(null); const theme = useMemo(() => getTheme(), [getTheme]); const dataContainer = useMemo((): FlameGraphDataContainer | undefined => { @@ -127,233 +122,222 @@ const FlameGraphContainer = ({ return; } - const container = new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme); - setCollapsedMap(container.getCollapsedMap()); - return container; + return new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme); }, [data, theme, disableCollapsing]); - const [colorScheme, setColorScheme] = useColorScheme(dataContainer); + const styles = getStyles(theme); const matchedLabels = useLabelSearch(search, dataContainer); - // If user resizes window with both as the selected view + // Handle responsive layout: switch away from Both view when narrow, restore when wide again useEffect(() => { - if ( - containerWidth > 0 && - containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH && - selectedView === SelectedView.Both && - !vertical - ) { - setSelectedView(SelectedView.FlameGraph); - } - }, [selectedView, setSelectedView, containerWidth, vertical]); - - const resetFocus = useCallback(() => { - setFocusedItemData(undefined); - setRangeMin(0); - setRangeMax(1); - }, [setFocusedItemData, setRangeMax, setRangeMin]); - - const resetSandwich = useCallback(() => { - setSandwichItem(undefined); - }, [setSandwichItem]); - - useEffect(() => { - if (!keepFocusOnDataChange) { - resetFocus(); - resetSandwich(); + if (containerWidth === 0) { return; } - if (dataContainer && focusedItemData) { - const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0]; + const isNarrow = containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH && !vertical; - 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); - } + if (isNarrow && selectedView === SelectedView.Multi) { + // Going narrow: save current view and switch to FlameGraph + setViewBeforeNarrow(SelectedView.Multi); + setSelectedView(SelectedView.FlameGraph); + } else if (!isNarrow && viewBeforeNarrow !== null) { + // Going wide again: restore the previous view + setSelectedView(viewBeforeNarrow); + setViewBeforeNarrow(null); } - }, [dataContainer, keepFocusOnDataChange]); // eslint-disable-line react-hooks/exhaustive-deps - - const onSymbolClick = useCallback( - (symbol: string) => { - const anchored = `^${escapeStringForRegex(symbol)}$`; - - if (search === anchored) { - setSearch(''); - } else { - onTableSymbolClick?.(symbol); - setSearch(anchored); - resetFocus(); - } - }, - [setSearch, resetFocus, onTableSymbolClick, search] - ); - - // Separate callback for CallTree that doesn't trigger search - const onCallTreeSymbolClick = useCallback( - (symbol: string) => { - onTableSymbolClick?.(symbol); - }, - [onTableSymbolClick] - ); - - // Search callback for CallTree search button - const onCallTreeSearch = useCallback( - (symbol: string) => { - const anchored = `^${escapeStringForRegex(symbol)}$`; - - if (search === anchored) { - setSearch(''); - } else { - onTableSymbolClick?.(symbol); - setSearch(anchored); - resetFocus(); - } - }, - [setSearch, resetFocus, onTableSymbolClick, search] - ); + }, [containerWidth, vertical, selectedView, viewBeforeNarrow]); if (!dataContainer) { return null; } - const flameGraph = ( - setFocusedItemData(data)} - focusedItemData={focusedItemData} - textAlign={textAlign} - onTextAlignChange={(align) => { - setTextAlign(align); - onTextAlignSelected?.(align); - }} - sandwichItem={sandwichItem} - onSandwich={(label: string) => { - resetFocus(); - setSandwichItem(label); - }} - onFocusPillClick={resetFocus} - onSandwichPillClick={resetSandwich} - colorScheme={colorScheme} - onColorSchemeChange={setColorScheme} - isDiffMode={dataContainer.isDiffFlamegraph()} - showFlameGraphOnly={showFlameGraphOnly} - collapsing={!disableCollapsing} - getExtraContextMenuButtons={getExtraContextMenuButtons} - selectedView={selectedView} - search={search} - collapsedMap={collapsedMap} - setCollapsedMap={setCollapsedMap} - /> - ); - - const table = ( - { - if (!str) { - setSearch(''); - return; - } - setSearch(`^${escapeStringForRegex(str)}$`); - }} - onTableSort={onTableSort} - colorScheme={colorScheme} - /> - ); - - // 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; + body = ( + + ); } else if (selectedView === SelectedView.TopTable) { - body =
{table}
; + body = ( + + ); } else if (selectedView === SelectedView.CallTree) { - body =
{callTree}
; - } else if (selectedView === SelectedView.Both) { + body = ( + + ); + } else if (selectedView === SelectedView.Multi) { // 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 { // Single view mode - body =
{renderPane(singleView)}
; + body = ( +
+ +
+ ); } } @@ -381,10 +365,11 @@ const FlameGraphContainer = ({ setSingleView={setSingleView} containerWidth={containerWidth} onReset={() => { - resetFocus(); - resetSandwich(); + // Reset search and pane states when user clicks reset button + setSearch(''); + setResetKey((k) => k + 1); }} - showResetButton={Boolean(focusedItemData || sandwichItem)} + showResetButton={Boolean(search)} stickyHeader={Boolean(stickyHeader)} extraHeaderElements={extraHeaderElements} vertical={vertical} @@ -398,18 +383,6 @@ const FlameGraphContainer = ({ ); }; -function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) { - const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased; - const [colorScheme, setColorScheme] = useState(defaultColorScheme); - - // This makes sure that if we change the data to/from diff profile we reset the color scheme. - useEffect(() => { - setColorScheme(defaultColorScheme); - }, [defaultColorScheme]); - - return [colorScheme, setColorScheme] as const; -} - /** * Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later. */ @@ -497,12 +470,6 @@ function getStyles(theme: GrafanaTheme2) { flexGrow: 1, }), - tableContainer: css({ - // This is not ideal for dashboard panel where it creates a double scroll. In a panel it should be 100% but then - // in explore we need a specific height. - height: 800, - }), - horizontalContainer: css({ label: 'horizontalContainer', display: 'flex', diff --git a/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx b/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx index 0177839aafc..62273bd0be1 100644 --- a/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphHeader.test.tsx @@ -29,7 +29,7 @@ describe('FlameGraphHeader', () => { - {selectedView === SelectedView.Both && viewMode === ViewMode.Split && ( + {selectedView === SelectedView.Multi && viewMode === ViewMode.Split && (
size="sm" @@ -131,7 +131,7 @@ const FlameGraphHeader = ({ /> )} {extraHeaderElements &&
{extraHeaderElements}
} - {selectedView === SelectedView.Both ? ( + {selectedView === SelectedView.Multi ? ( <> {viewMode === ViewMode.Single && ( @@ -182,8 +182,8 @@ function getViewOptions(width: number, vertical?: boolean): Array= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH || vertical) { viewOptions.push({ - value: SelectedView.Both, - label: 'Both', + value: SelectedView.Multi, + label: 'Multi', description: 'Show split or single view with multiple visualizations', }); } diff --git a/packages/grafana-flamegraph/src/FlameGraphPane.tsx b/packages/grafana-flamegraph/src/FlameGraphPane.tsx new file mode 100644 index 00000000000..e6d725b9709 --- /dev/null +++ b/packages/grafana-flamegraph/src/FlameGraphPane.tsx @@ -0,0 +1,269 @@ +import { css } from '@emotion/css'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { GrafanaTheme2, escapeStringForRegex } from '@grafana/data'; + +import FlameGraphCallTreeContainer from './CallTree/FlameGraphCallTreeContainer'; +import FlameGraph from './FlameGraph/FlameGraph'; +import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu'; +import { CollapsedMap, FlameGraphDataContainer } from './FlameGraph/dataTransform'; +import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer'; +import { ClickedItemData, ColorScheme, ColorSchemeDiff, PaneView, SelectedView, TextAlign, ViewMode } from './types'; + +export type FlameGraphPaneProps = { + paneView: PaneView; + dataContainer: FlameGraphDataContainer; + search: string; + matchedLabels: Set | undefined; + onTableSymbolClick?: (symbol: string) => void; + onTextAlignSelected?: (align: string) => void; + onTableSort?: (sort: string) => void; + showFlameGraphOnly?: boolean; + disableCollapsing?: boolean; + getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction; + selectedView: SelectedView; + viewMode: ViewMode; + theme: GrafanaTheme2; + setSearch: (search: string) => void; + /** When this key changes, the pane's internal state (focus, sandwich, etc.) will be reset */ + resetKey?: number; + /** Whether to preserve focus when the data changes */ + keepFocusOnDataChange?: boolean; +}; + +const FlameGraphPane = ({ + paneView, + dataContainer, + search, + matchedLabels, + onTableSymbolClick, + onTextAlignSelected, + onTableSort, + showFlameGraphOnly, + disableCollapsing, + getExtraContextMenuButtons, + selectedView, + viewMode, + theme, + setSearch, + resetKey, + keepFocusOnDataChange, +}: FlameGraphPaneProps) => { + // Pane-specific state - each instance maintains its own + const [focusedItemData, setFocusedItemData] = useState(); + const [rangeMin, setRangeMin] = useState(0); + const [rangeMax, setRangeMax] = useState(1); + const [textAlign, setTextAlign] = useState('left'); + const [sandwichItem, setSandwichItem] = useState(); + const [collapsedMap, setCollapsedMap] = useState(new CollapsedMap()); + const [colorScheme, setColorScheme] = useColorScheme(dataContainer); + + const styles = useMemo(() => getStyles(theme), [theme]); + + // Initialize collapsed map when dataContainer changes + useEffect(() => { + if (dataContainer) { + setCollapsedMap(dataContainer.getCollapsedMap()); + } + }, [dataContainer]); + + // Reset internal state when resetKey changes (triggered by parent's reset button) + useEffect(() => { + if (resetKey !== undefined && resetKey > 0) { + setFocusedItemData(undefined); + setRangeMin(0); + setRangeMax(1); + setSandwichItem(undefined); + } + }, [resetKey]); + + // Handle focus preservation or reset when data changes + useEffect(() => { + if (!keepFocusOnDataChange) { + setFocusedItemData(undefined); + setRangeMin(0); + setRangeMax(1); + setSandwichItem(undefined); + 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); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataContainer, keepFocusOnDataChange]); + + const resetFocus = useCallback(() => { + setFocusedItemData(undefined); + setRangeMin(0); + setRangeMax(1); + }, []); + + const resetSandwich = useCallback(() => { + setSandwichItem(undefined); + }, []); + + const onSymbolClick = useCallback( + (symbol: string) => { + const anchored = `^${escapeStringForRegex(symbol)}$`; + if (search === anchored) { + setSearch(''); + } else { + onTableSymbolClick?.(symbol); + setSearch(anchored); + resetFocus(); + } + }, + [search, setSearch, resetFocus, onTableSymbolClick] + ); + + // Separate callback for CallTree that doesn't trigger search + const onCallTreeSymbolClick = useCallback( + (symbol: string) => { + onTableSymbolClick?.(symbol); + }, + [onTableSymbolClick] + ); + + // Search callback for CallTree search button + const onCallTreeSearch = useCallback( + (symbol: string) => { + const anchored = `^${escapeStringForRegex(symbol)}$`; + if (search === anchored) { + setSearch(''); + } else { + onTableSymbolClick?.(symbol); + setSearch(anchored); + resetFocus(); + } + }, + [search, setSearch, resetFocus, onTableSymbolClick] + ); + + const isInSplitView = selectedView === SelectedView.Multi && viewMode === ViewMode.Split; + const isCallTreeInSplitView = isInSplitView && paneView === PaneView.CallTree; + + switch (paneView) { + case PaneView.TopTable: + return ( +
+ { + if (!str) { + setSearch(''); + return; + } + setSearch(`^${escapeStringForRegex(str)}$`); + }} + onTableSort={onTableSort} + colorScheme={colorScheme} + /> +
+ ); + case PaneView.FlameGraph: + default: + return ( + setFocusedItemData(data)} + focusedItemData={focusedItemData} + textAlign={textAlign} + onTextAlignChange={(align) => { + setTextAlign(align); + onTextAlignSelected?.(align); + }} + sandwichItem={sandwichItem} + onSandwich={(label: string) => { + resetFocus(); + setSandwichItem(label); + }} + onFocusPillClick={resetFocus} + onSandwichPillClick={resetSandwich} + colorScheme={colorScheme} + onColorSchemeChange={setColorScheme} + isDiffMode={dataContainer.isDiffFlamegraph()} + showFlameGraphOnly={showFlameGraphOnly} + collapsing={!disableCollapsing} + getExtraContextMenuButtons={getExtraContextMenuButtons} + selectedView={selectedView} + search={search} + collapsedMap={collapsedMap} + setCollapsedMap={setCollapsedMap} + /> + ); + case PaneView.CallTree: + return ( +
+ +
+ ); + } +}; + +function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) { + const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased; + const [colorScheme, setColorScheme] = useState(defaultColorScheme); + + // This makes sure that if we change the data to/from diff profile we reset the color scheme. + useEffect(() => { + setColorScheme(defaultColorScheme); + }, [defaultColorScheme]); + + return [colorScheme, setColorScheme] as const; +} + +function getStyles(theme: GrafanaTheme2) { + return { + tableContainer: css({ + // This is not ideal for dashboard panel where it creates a double scroll. In a panel it should be 100% but then + // in explore we need a specific height. + height: 800, + }), + }; +} + +export default FlameGraphPane; diff --git a/packages/grafana-flamegraph/src/types.ts b/packages/grafana-flamegraph/src/types.ts index 48f11fd9abd..10be9137c69 100644 --- a/packages/grafana-flamegraph/src/types.ts +++ b/packages/grafana-flamegraph/src/types.ts @@ -20,7 +20,7 @@ export enum SampleUnit { export enum SelectedView { TopTable = 'topTable', FlameGraph = 'flameGraph', - Both = 'both', + Multi = 'multi', CallTree = 'callTree', }