From 8bc405d5ed238355921ca319fb1ff4ce224f7154 Mon Sep 17 00:00:00 2001 From: Aleksandar Petrov <8142643+aleks-p@users.noreply.github.com> Date: Fri, 9 Jan 2026 15:46:39 -0400 Subject: [PATCH] Add support to show callers in call tree --- .../FlameGraphCallTreeContainer.story.tsx | 3 - .../CallTree/FlameGraphCallTreeContainer.tsx | 604 +- .../grafana-flamegraph/src/CallTree/utils.ts | 491 +- .../src/FlameGraph/testData/dataNestedSet.ts | 73751 +++++++++++++++- .../src/utils/storybook/withTheme.tsx | 37 +- 5 files changed, 72063 insertions(+), 2823 deletions(-) diff --git a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx index 5cc533f38bc..88eb4d898ad 100644 --- a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx +++ b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx @@ -36,9 +36,6 @@ export const Basic: StoryObj = { onSymbolClick={(symbol) => { console.log('Symbol clicked:', symbol); }} - onSearch={(search) => { - console.log('Search:', search); - }} onSandwich={(item) => { console.log('Sandwich:', item); }} diff --git a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx index d3daf8a8f7b..a10ff787e87 100644 --- a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx +++ b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx @@ -1,147 +1,236 @@ import { css, cx } from '@emotion/css'; -import { memo, useEffect, useMemo, useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { useTable, useSortBy, useExpanded, Column, Row, UseExpandedRowProps } from 'react-table'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { useDebounce, usePrevious } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Dropdown, Input, Menu, useStyles2, useTheme2 } from '@grafana/ui'; +import { Button, Input, useStyles2, useTheme2 } from '@grafana/ui'; -import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from '../FlameGraph/colors'; import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from '../FlameGraph/colors'; import { FlameGraphDataContainer } from '../FlameGraph/dataTransform'; -import { labelSearch } from '../FlameGraphContainer'; import { ColorScheme, ColorSchemeDiff } from '../types'; import { buildAllCallTreeNodes, + buildCallersTreeFromLevels, CallTreeNode, - filterCallTree, - getExpandedStateForMatches, getInitialExpandedState, } from './utils'; type Props = { data: FlameGraphDataContainer; onSymbolClick: (symbol: string) => void; - search?: string; - matchedLabels?: Set; sandwichItem?: string; - onSearch: (str: string) => void; onSandwich: (str?: string) => void; onTableSort?: (sort: string) => void; colorScheme: ColorScheme | ColorSchemeDiff; }; const FlameGraphCallTreeContainer = memo( - ({ data, onSymbolClick, search, matchedLabels, onSearch, sandwichItem, onSandwich, onTableSort, colorScheme: initialColorScheme }: Props) => { + ({ data, onSymbolClick, sandwichItem, onSandwich, onTableSort, colorScheme: initialColorScheme }: Props) => { const styles = useStyles2(getStyles); const theme = useTheme2(); - // Color scheme state - const [colorScheme, setColorScheme] = useState(initialColorScheme); + // Use package-based color scheme by default + const colorScheme = data.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased; // Focus state - track which node is focused const [focusedNodeId, setFocusedNodeId] = useState(undefined); - // Update color scheme when prop changes - useEffect(() => { - setColorScheme(initialColorScheme); - }, [initialColorScheme]); + // Callers state - track which function's callers we're showing + const [callersNodeLabel, setCallersNodeLabel] = useState(undefined); - // Search with debouncing - const [localSearch, setLocalSearch] = useSearchInput(search || '', onSearch); - - // Get matched labels from search - const searchMatchedLabels = useMemo(() => { - if (!localSearch) { - return undefined; + // Wrapper functions for mutual exclusivity + const handleSetFocusMode = (nodeIdOrLabel: string | undefined, isLabel: boolean = false) => { + if (nodeIdOrLabel === undefined) { + setFocusedNodeId(undefined); + } else if (isLabel) { + // When switching from callers mode, we need to find the node by label in the normal tree + // We'll set a special marker that the useMemo will use to find the node + setFocusedNodeId(`label:${nodeIdOrLabel}`); + } else { + setFocusedNodeId(nodeIdOrLabel); } - return labelSearch(localSearch, data); - }, [localSearch, data]); - // Use search-based matched labels if available, otherwise use prop - const effectiveMatchedLabels = searchMatchedLabels || matchedLabels; + if (nodeIdOrLabel !== undefined) { + setCallersNodeLabel(undefined); + } + }; - // Build and filter nodes - const { nodes, matchingIds, focusedNode } = useMemo(() => { + const handleSetCallersMode = (label: string | undefined) => { + setCallersNodeLabel(label); + if (label !== undefined) { + setFocusedNodeId(undefined); + } + }; + + // Build nodes + const { nodes, focusedNode, callersNode } = useMemo(() => { const allNodes = buildAllCallTreeNodes(data); - // If there's a focused node, find it and use its subtree + // If there's a focused node, find it and use its subtree with parent let nodesToUse = allNodes; let focused: CallTreeNode | undefined; + let callersTargetNode: CallTreeNode | undefined; if (focusedNodeId) { + // Check if we're searching by label (when switching from callers mode) + const isLabelSearch = focusedNodeId.startsWith('label:'); + const searchKey = isLabelSearch ? focusedNodeId.substring(6) : focusedNodeId; + // Find the focused node in the tree - const findNode = (nodes: CallTreeNode[], id: string): CallTreeNode | undefined => { + const findNode = (nodes: CallTreeNode[], searchKey: string, byLabel: boolean): CallTreeNode | undefined => { for (const node of nodes) { - if (node.id === id) { + if (byLabel ? node.label === searchKey : node.id === searchKey) { return node; } if (node.subRows) { - const found = findNode(node.subRows, id); + const found = findNode(node.subRows, searchKey, byLabel); if (found) return found; } } return undefined; }; - focused = findNode(allNodes, focusedNodeId); + focused = findNode(allNodes, searchKey, isLabelSearch); if (focused) { - // Use the focused node as the root - nodesToUse = [focused]; + // If we searched by label, update the focusedNodeId to use the actual ID + if (isLabelSearch) { + // Update state to use the actual ID for future operations + // We do this asynchronously to avoid updating state during render + setTimeout(() => setFocusedNodeId(focused!.id), 0); + } + + // If the focused node has a parent, show parent with focused node as only child + if (focused.parentId) { + const parent = findNode(allNodes, focused.parentId, false); + if (parent) { + // Create a modified parent that only shows the focused node as its child + const modifiedParent: CallTreeNode = { + ...parent, + subRows: [focused], + hasChildren: true, + childCount: 1, + }; + nodesToUse = [modifiedParent]; + } else { + // Parent not found, just use focused node + nodesToUse = [focused]; + } + } else { + // No parent, use the focused node as the root + nodesToUse = [focused]; + } } } - const { visibleNodes, matchingNodeIds } = filterCallTree(nodesToUse, effectiveMatchedLabels); - return { nodes: visibleNodes, matchingIds: matchingNodeIds, focusedNode: focused }; - }, [data, effectiveMatchedLabels, focusedNodeId]); + // If there's a callers mode active, build the inverted tree + if (callersNodeLabel) { + const [callers, _] = data.getSandwichLevels(callersNodeLabel); - // Calculate expanded state based on search - const calculatedExpanded = useMemo(() => { - const baseExpanded = getInitialExpandedState(nodes, 2); - console.log('Calculated base expanded:', { - numBaseExpanded: Object.keys(baseExpanded).filter(k => baseExpanded[k]).length, - totalNodes: nodes.length, - firstNode: nodes[0]?.id, - expandedKeys: Object.keys(baseExpanded).filter(k => baseExpanded[k]), - }); + if (callers.length > 0 && callers[0].length > 0) { + const levels = data.getLevels(); + const rootTotal = levels.length > 0 ? levels[0][0].value : 0; - if (effectiveMatchedLabels && effectiveMatchedLabels.size > 0) { - const matchExpanded = getExpandedStateForMatches(nodes, matchingIds); - console.log('Adding match expansion:', { - numMatchExpanded: Object.keys(matchExpanded).filter(k => matchExpanded[k]).length, - matchExpandedKeys: Object.keys(matchExpanded).filter(k => matchExpanded[k]), - matchingIds: Array.from(matchingIds), - hasSearch: true, - }); - return { ...baseExpanded, ...matchExpanded }; + // Build inverted tree directly with target as root and callers as children + const { tree, targetNode } = buildCallersTreeFromLevels(callers, callersNodeLabel, data, rootTotal); + + nodesToUse = tree; + callersTargetNode = targetNode; + } else { + // No callers found - show empty tree + nodesToUse = []; + callersTargetNode = undefined; + } } + + return { nodes: nodesToUse, focusedNode: focused, callersNode: callersTargetNode }; + }, [data, focusedNodeId, callersNodeLabel]); + + // Calculate expanded state + const calculatedExpanded = useMemo(() => { + const baseExpanded = getInitialExpandedState(nodes, 1); + + // If there's a focused node, expand to show its children + if (focusedNodeId && nodes.length > 0) { + const rootNode = nodes[0]; + + // Check if focusedNodeId is a label-based search + const isLabelSearch = focusedNodeId.startsWith('label:'); + const searchLabel = isLabelSearch ? focusedNodeId.substring(6) : undefined; + + // Always expand the root to show the focused node + if (rootNode.hasChildren) { + baseExpanded['0'] = true; + } + + // If the root is the parent (not the focused node itself), also expand the focused node + const isRootTheFocusedNode = isLabelSearch + ? rootNode.label === searchLabel + : rootNode.id === focusedNodeId; + + if (!isRootTheFocusedNode && rootNode.hasChildren) { + // The focused node is at "0.0" (first child of parent) + baseExpanded['0.0'] = true; + } + } + + // If in callers mode, expand to show the target node + if (callersNodeLabel && callersNode && nodes.length > 0) { + // Find path from root to target node and expand all nodes along the path + const expandPathToNode = (nodes: CallTreeNode[], targetId: string): boolean => { + for (const node of nodes) { + if (node.id === targetId) { + // Found the target - don't expand it yet, but confirm path + return true; + } + if (node.subRows && node.hasChildren) { + // Check if target is in this subtree + const foundInSubtree = expandPathToNode(node.subRows, targetId); + if (foundInSubtree) { + // Target is in this subtree, so expand this node + baseExpanded[node.id] = true; + return true; + } + } + } + return false; + }; + + // Expand path to target + expandPathToNode(nodes, callersNode.id); + + // Also expand the target node itself to show its immediate callers (children in this view) + if (callersNode.hasChildren) { + baseExpanded[callersNode.id] = true; + } + } + return baseExpanded; - }, [nodes, effectiveMatchedLabels, matchingIds]); + }, [nodes, focusedNodeId, callersNodeLabel, callersNode]); // Create a key that changes when expansion should reset, forcing table remount const tableKey = useMemo(() => { // Include matchingIds in the key so table remounts when search results change const expandedKeys = Object.keys(calculatedExpanded).filter(k => calculatedExpanded[k]).sort().join(','); const focusPart = focusedNodeId ? `-focus-${focusedNodeId}` : ''; - return `table-${expandedKeys}${focusPart}`; - }, [calculatedExpanded, focusedNodeId]); + const callersPart = callersNodeLabel ? `-callers-${callersNodeLabel}` : ''; + return `table-${expandedKeys}${focusPart}${callersPart}`; + }, [calculatedExpanded, focusedNodeId, callersNodeLabel]); // Define columns - const columns: Column[] = useMemo(() => { + const columns = useMemo[]>(() => { if (data.isDiffFlamegraph()) { return [ { Header: '', id: 'actions', - Cell: ({ row }: { row: Row & UseExpandedRowProps }) => ( + Cell: ({ row }: any) => ( { - setFocusedNodeId(nodeId); - setLocalSearch(''); - }} - isFocusedRoot={row.id === '0'} + onFocus={handleSetFocusMode} + onShowCallers={handleSetCallersMode} + focusedNodeId={focusedNodeId} + callersNodeLabel={callersNodeLabel} styles={styles} /> ), @@ -160,8 +249,6 @@ const FlameGraphCallTreeContainer = memo( hasChildren={row.original.hasChildren} rowIndex={rowIndex} rows={tableInstance.rows} - isMatching={matchingIds.has(row.original.id)} - hasFilter={effectiveMatchedLabels !== undefined && effectiveMatchedLabels.size > 0} onSymbolClick={onSymbolClick} styles={styles} allNodes={nodes} @@ -174,7 +261,7 @@ const FlameGraphCallTreeContainer = memo( Header: '', id: 'colorBar', Cell: ({ row }: { row: Row }) => ( - + ), minWidth: 200, width: 200, @@ -209,14 +296,13 @@ const FlameGraphCallTreeContainer = memo( { Header: '', id: 'actions', - Cell: ({ row }: { row: Row & UseExpandedRowProps }) => ( + Cell: ({ row }: any) => ( { - setFocusedNodeId(nodeId); - setLocalSearch(''); - }} - isFocusedRoot={row.id === '0'} + onFocus={handleSetFocusMode} + onShowCallers={handleSetCallersMode} + focusedNodeId={focusedNodeId} + callersNodeLabel={callersNodeLabel} styles={styles} /> ), @@ -235,8 +321,6 @@ const FlameGraphCallTreeContainer = memo( hasChildren={row.original.hasChildren} rowIndex={rowIndex} rows={tableInstance.rows} - isMatching={matchingIds.has(row.original.id)} - hasFilter={effectiveMatchedLabels !== undefined && effectiveMatchedLabels.size > 0} onSymbolClick={onSymbolClick} styles={styles} allNodes={nodes} @@ -249,7 +333,7 @@ const FlameGraphCallTreeContainer = memo( Header: '', id: 'colorBar', Cell: ({ row }: { row: Row }) => ( - + ), minWidth: 200, width: 200, @@ -291,7 +375,7 @@ const FlameGraphCallTreeContainer = memo( }, ]; } - }, [data, effectiveMatchedLabels, matchingIds, onSymbolClick, colorScheme, theme, styles, setLocalSearch]); + }, [data, onSymbolClick, colorScheme, theme, styles, focusedNode, callersNode, callersNodeLabel]); // 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 @@ -314,35 +398,7 @@ const FlameGraphCallTreeContainer = memo( useExpanded ); - const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, state } = tableInstance; - - console.log('Table state:', { - visibleRows: rows.length, - expandedState: Object.keys(state.expanded).filter(k => state.expanded[k]).length, - expandedIds: Object.keys(state.expanded).filter(k => state.expanded[k]), - firstFewRows: rows.slice(0, 5).map(r => ({ - id: r.id, - originalId: r.original.id, - label: r.original.label, - isExpanded: (r as any).isExpanded, - hasSubRows: !!r.original.subRows - })), - rootNodeSubRows: nodes[0]?.subRows?.length, - }); - - const clearSearchSuffix = - localSearch !== '' ? ( - - ) : null; + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = tableInstance; return (
@@ -350,33 +406,44 @@ const FlameGraphCallTreeContainer = memo(
{ - setLocalSearch(v.currentTarget.value); - }} + value={''} + disabled placeholder={'Search...'} - suffix={clearSearchSuffix} className={styles.searchInput} />
{focusedNode && (
-
+ )} + + {callersNode && ( +
+
)}
-
@@ -424,8 +491,18 @@ const FlameGraphCallTreeContainer = memo( {rows.map((row, rowIndex) => { prepareRow(row); const { key, ...rowProps } = row.getRowProps(); + const isFocusedRow = row.original.id === focusedNodeId; + const isCallersTargetRow = callersNodeLabel && row.original.label === callersNodeLabel; return ( - + {row.cells.map((cell) => { const { key: cellKey, ...cellProps } = cell.getCellProps(); const isValueColumn = cell.column.id === 'self' || cell.column.id === 'total'; @@ -457,67 +534,6 @@ const FlameGraphCallTreeContainer = memo( FlameGraphCallTreeContainer.displayName = 'FlameGraphCallTreeContainer'; -// ColorSchemeButton component -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)} /> - - ); - - 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 ( - - - - ); -} - // Helper function to get row background color function getRowBackgroundColor( node: CallTreeNode, @@ -559,30 +575,82 @@ function getRowBackgroundColor( function ActionsCell({ row, onFocus, - isFocusedRoot, + onShowCallers, + focusedNodeId, + callersNodeLabel, styles, }: { row: Row & UseExpandedRowProps; - onFocus: (nodeId: string) => void; - isFocusedRoot: boolean; + onFocus: (nodeIdOrLabel: string, isLabel?: boolean) => void; + onShowCallers: (label: string) => void; + focusedNodeId: string | undefined; + callersNodeLabel: string | undefined; styles: any; }) { + const hasChildren = row.original.hasChildren; + const isTheFocusedNode = row.original.id === focusedNodeId || + (focusedNodeId?.startsWith('label:') && focusedNodeId.substring(6) === row.original.label); + const isTheCallersTarget = row.original.label === callersNodeLabel; + const inCallersMode = callersNodeLabel !== undefined; + const inFocusMode = focusedNodeId !== undefined; + const isRootNode = row.original.depth === 0 && !row.original.parentId; + + // Show focus button if: + // - Node has children AND + // - Node is not the currently focused node AND + // - If it's the root node, only show when in focus mode (as parent) + // Allow switching from callers mode to focus mode + const shouldShowFocusButton = hasChildren && !isTheFocusedNode && !(isRootNode && !inFocusMode); + + // Show callers button if: + // - Node is not the current callers target AND + // - Node is not the root node + // Allow switching from focus mode to callers mode + const shouldShowCallersButton = !isTheCallersTarget && !isRootNode; + return (
- {!isFocusedRoot && ( -
); } @@ -594,8 +662,6 @@ function FunctionCellWithExpander({ hasChildren, rowIndex, rows, - isMatching, - hasFilter, onSymbolClick, styles, allNodes, @@ -606,15 +672,10 @@ function FunctionCellWithExpander({ hasChildren: boolean; rowIndex?: number; rows: Array>; - isMatching: boolean; - hasFilter: boolean; onSymbolClick: (symbol: string) => void; styles: any; allNodes: CallTreeNode[]; }) { - const opacity = hasFilter && !isMatching ? 0.5 : 1; - const fontWeight = hasFilter && isMatching ? 'bold' : 'normal'; - const handleClick = () => { if (hasChildren) { row.toggleRowExpanded(); @@ -719,7 +780,7 @@ function FunctionCellWithExpander({ const connector = buildTreeConnector(); return ( -
+
{connector && {connector} }