From 665a54f02f03faa5bbb529a6ff7b40cc8065688d Mon Sep 17 00:00:00 2001 From: Aleksandar Petrov <8142643+aleks-p@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:19:35 -0400 Subject: [PATCH] First iteration of call tree profile visualization --- .../FlameGraphCallTreeContainer.story.tsx | 48 + .../CallTree/FlameGraphCallTreeContainer.tsx | 880 ++++++++++++++++++ .../grafana-flamegraph/src/CallTree/utils.ts | 281 ++++++ 3 files changed, 1209 insertions(+) create mode 100644 packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx create mode 100644 packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx create mode 100644 packages/grafana-flamegraph/src/CallTree/utils.ts diff --git a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx new file mode 100644 index 00000000000..5cc533f38bc --- /dev/null +++ b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.story.tsx @@ -0,0 +1,48 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { createDataFrame } from '@grafana/data'; + +import { FlameGraphDataContainer } from '../FlameGraph/dataTransform'; +import { data } from '../FlameGraph/testData/dataNestedSet'; +import { ColorScheme } from '../types'; + +import FlameGraphCallTreeContainer from './FlameGraphCallTreeContainer'; + +const meta: Meta = { + title: 'CallTree', + component: FlameGraphCallTreeContainer, + args: { + colorScheme: ColorScheme.PackageBased, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +export const Basic: StoryObj = { + render: (args) => { + const dataContainer = new FlameGraphDataContainer(createDataFrame(data), { collapsing: true }); + + return ( + { + 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 new file mode 100644 index 00000000000..05eebe1056b --- /dev/null +++ b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx @@ -0,0 +1,880 @@ +import { css, cx } from '@emotion/css'; +import { memo, useEffect, 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, ButtonGroup, Dropdown, Input, Menu, 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, + 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) => { + const styles = useStyles2(getStyles); + const theme = useTheme2(); + + // Color scheme state + const [colorScheme, setColorScheme] = useState(initialColorScheme); + + // Update color scheme when prop changes + useEffect(() => { + setColorScheme(initialColorScheme); + }, [initialColorScheme]); + + // Search with debouncing + const [localSearch, setLocalSearch] = useSearchInput(search || '', onSearch); + + // Get matched labels from search + const searchMatchedLabels = useMemo(() => { + if (!localSearch) { + return undefined; + } + return labelSearch(localSearch, data); + }, [localSearch, data]); + + // Use search-based matched labels if available, otherwise use prop + const effectiveMatchedLabels = searchMatchedLabels || matchedLabels; + + // Build and filter nodes + const { nodes, matchingIds } = useMemo(() => { + const allNodes = buildAllCallTreeNodes(data); + const { visibleNodes, matchingNodeIds } = filterCallTree(allNodes, effectiveMatchedLabels); + return { nodes: visibleNodes, matchingIds: matchingNodeIds }; + }, [data, effectiveMatchedLabels]); + + // 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 (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 }; + } + return baseExpanded; + }, [nodes, effectiveMatchedLabels, matchingIds]); + + // 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(','); + return `table-${expandedKeys}`; + }, [calculatedExpanded]); + + + // Define columns + const columns: Column[] = useMemo(() => { + if (data.isDiffFlamegraph()) { + return [ + { + Header: 'Function', + accessor: 'label', + Cell: ({ row, value, rowIndex }: { row: Row; value: string; rowIndex?: number }) => ( + & UseExpandedRowProps} + value={value} + depth={row.original.depth} + 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} + /> + ), + minWidth: 200, + width: undefined, + }, + { + Header: '', + id: 'colorBar', + Cell: ({ row }: { row: Row }) => ( + + ), + minWidth: 200, + width: 200, + disableSortBy: true, + }, + { + Header: 'Baseline %', + accessor: 'selfPercent', + Cell: ({ value }: { value: number }) => `${value.toFixed(2)}%`, + sortType: 'basic', + width: 100, + }, + { + Header: 'Comparison %', + accessor: 'selfPercentRight', + Cell: ({ value }: { value: number | undefined }) => (value !== undefined ? `${value.toFixed(2)}%` : '-'), + sortType: 'basic', + width: 100, + }, + { + Header: 'Diff %', + accessor: 'diffPercent', + Cell: ({ value }: { value: number | undefined }) => ( + + ), + sortType: 'basic', + width: 100, + }, + ]; + } else { + return [ + { + Header: 'Function', + accessor: 'label', + Cell: ({ row, value, rowIndex }: { row: Row; value: string; rowIndex?: number }) => ( + & UseExpandedRowProps} + value={value} + depth={row.original.depth} + 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} + /> + ), + 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, + }, + ]; + } + }, [data, effectiveMatchedLabels, matchingIds, onSymbolClick, colorScheme, theme, styles]); + + // Setup table instance with expand and sort + // Using initialState - expansion resets when data changes so new initialState takes effect + const tableInstance = useTable( + { + columns, + data: nodes, + getSubRows: (row) => row.subRows || [], + initialState: { + expanded: calculatedExpanded, + sortBy: [{ id: 'total', desc: true }], + }, + autoResetExpanded: true, // Reset expansion when data changes so initialState applies + autoResetSortBy: false, + }, + useSortBy, + useExpanded + ); + + const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, state, toggleAllRowsExpanded } = 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; + + return ( +
+ {/* Toolbar */} +
+
+ { + setLocalSearch(v.currentTarget.value); + }} + placeholder={'Search...'} + suffix={clearSearchSuffix} + className={styles.searchInput} + /> +
+ +
+ + +
+
+ + + {({ width, height }) => { + if (width < 3 || height < 3) { + return null; + } + + return ( +
+ + + {headerGroups.map((headerGroup) => { + const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps(); + return ( + + {headerGroup.headers.map((column) => { + const { key: headerKey, ...headerProps } = column.getHeaderProps( + column.getSortByToggleProps() + ); + return ( + + ); + })} + + ); + })} + + + {rows.map((row, rowIndex) => { + prepareRow(row); + const { key, ...rowProps } = row.getRowProps(); + return ( + + {row.cells.map((cell) => { + const { key: cellKey, ...cellProps } = cell.getCellProps(); + const isValueColumn = cell.column.id === 'self' || cell.column.id === 'total'; + return ( + + ); + })} + + ); + })} + +
+ {column.render('Header')} + + {column.isSorted ? (column.isSortedDesc ? ' ▼' : ' ▲') : ''} + +
+ {cell.render('Cell', { rowIndex })} +
+
+ ); + }} +
+
+ ); + } +); + +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, + data: FlameGraphDataContainer, + colorScheme: ColorScheme | ColorSchemeDiff, + theme: GrafanaTheme2 +): string { + if (data.isDiffFlamegraph()) { + // For diff profiles, use diff coloring + const levels = data.getLevels(); + const rootTotal = levels[0][0].value; + const rootTotalRight = levels[0][0].valueRight || 0; + + const barColor = getBarColorByDiff( + node.total, + node.totalRight || 0, + rootTotal, + rootTotalRight, + colorScheme as ColorSchemeDiff + ); + return barColor.setAlpha(1.0).toString(); + } else { + // For regular profiles + if (colorScheme === ColorScheme.ValueBased) { + const levels = data.getLevels(); + const rootTotal = levels[0][0].value; + const barColor = getBarColorByValue(node.total, rootTotal, 0, 1); + return barColor.setAlpha(1.0).toString(); + } else { + // PackageBased + const barColor = getBarColorByPackage(node.label, theme); + return barColor.setAlpha(1.0).toString(); + } + } +} + +// Cell Components + +function FunctionCellWithExpander({ + row, + value, + depth, + hasChildren, + rowIndex, + rows, + isMatching, + hasFilter, + onSymbolClick, + styles, + allNodes, +}: { + row: Row & UseExpandedRowProps; + value: string; + depth: number; + 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(); + } + onSymbolClick(value); + }; + + // Helper to check if a node at a given row index is the last visible child of its parent + const isLastVisibleChildAtIndex = (index: number): boolean => { + if (index === undefined) return false; + + const currentRow = rows[index]; + const parentId = currentRow.original.parentId; + + // Find the next sibling (same parent, appears after current row) + for (let i = index + 1; i < rows.length; i++) { + if (rows[i].original.parentId === parentId) { + return false; // Found a sibling, so not last + } + // If we encounter a node at same or higher level, stop searching + if (rows[i].original.depth <= currentRow.original.depth) { + break; + } + } + return true; // No more siblings found + }; + + // Build the tree connector string + const buildTreeConnector = () => { + if (depth === 0) { + return null; + } + + const lines: string[] = []; + + // For each ancestor level, determine if we need a vertical line + // We draw a vertical line if there are more siblings of that ancestor + + // Build a map of node ID to the actual node from rows (for flat access) + const nodeIdToNode = new Map(); + rows.forEach((r) => { + nodeIdToNode.set(r.original.id, r.original); + }); + + // Helper to check if there are more siblings at the target depth level after current row + const hasMoreNodesAtDepth = (targetDepth: number): boolean => { + if (rowIndex === undefined) { + return false; + } + + // Look through rows after the CURRENT row + for (let i = rowIndex + 1; i < rows.length; i++) { + const checkRow = rows[i]; + + // If we find a node at the target depth, there are more nodes at that level + if (checkRow.original.depth === targetDepth) { + return true; + } + + // If we've reached a node at depth < targetDepth, we've left the subtree + if (checkRow.original.depth < targetDepth) { + break; + } + } + return false; + }; + + // Walk up the parent chain to build ancestor list + // We need to collect all parent nodes (not including current node or root) + const ancestors: CallTreeNode[] = []; + let currentNode = row.original; + + // Walk up the parent chain using the parentId + while (currentNode.parentId && currentNode.depth > 0) { + const parent = nodeIdToNode.get(currentNode.parentId); + if (parent) { + ancestors.unshift(parent); + currentNode = parent; + } else { + break; + } + } + + // For each position before the current node's branch, check if vertical line needed + for (let i = 0; i < depth - 1; i++) { + // The vertical line at position i connects nodes at depth i+1 + // So check if there are more nodes at depth i+1 after the current row + if (hasMoreNodesAtDepth(i + 1)) { + lines.push('│ '); + } else { + lines.push(' '); + } + } + + // Add the branch character for the current node + const isLastChild = rowIndex !== undefined ? isLastVisibleChildAtIndex(rowIndex) : false; + lines.push(isLastChild ? '└─' : '├─'); + + return lines.join(''); + }; + + const connector = buildTreeConnector(); + + return ( +
+ {connector && {connector} } + +
+ ); +} + + +function ColorBarCell({ + node, + data, + colorScheme, + theme, + styles, +}: { + node: CallTreeNode; + data: FlameGraphDataContainer; + colorScheme: ColorScheme | ColorSchemeDiff; + theme: GrafanaTheme2; + styles: any; +}) { + const barColor = getRowBackgroundColor(node, data, colorScheme, theme); + const barWidth = `${Math.min(node.totalPercent, 100)}%`; + + return ( +
+
+
+ ); +} + +function DiffCell({ + value, + colorScheme, + theme, + styles, +}: { + value: number | undefined; + colorScheme: ColorScheme | ColorSchemeDiff; + theme: GrafanaTheme2; + styles: any; +}) { + if (value === undefined) { + return -; + } + + let displayValue: string; + let color: string; + + if (value === Infinity) { + displayValue = 'new'; + color = theme.colors.success.text; + } else if (value === -100) { + displayValue = 'removed'; + color = theme.colors.error.text; + } else { + displayValue = `${value > 0 ? '+' : ''}${value.toFixed(2)}%`; + color = value > 0 ? theme.colors.error.text : theme.colors.success.text; + } + + return {displayValue}; +} + +// Search hook with debouncing +function useSearchInput( + search: string, + setSearch: (search: string) => void +): [string, (search: string) => void] { + const [localSearchState, setLocalSearchState] = useState(search); + const prevSearch = usePrevious(search); + + // Debouncing cause changing parent search triggers rerender + useDebounce( + () => { + setSearch(localSearchState); + }, + 250, + [localSearchState] + ); + + // Make sure we still handle updates from parent (from clicking on a table item for example) + useEffect(() => { + if (prevSearch !== search && search !== localSearchState) { + setLocalSearchState(search); + } + }, [search, prevSearch, localSearchState]); + + return [localSearchState, setLocalSearchState]; +} + +// Styles + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + width: '100%', + height: '100%', + backgroundColor: theme.colors.background.primary, + display: 'flex', + flexDirection: 'column', + }), + toolbar: css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + gap: theme.spacing(1), + flexWrap: 'wrap', + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + toolbarLeft: css({ + flexGrow: 1, + minWidth: '150px', + maxWidth: '350px', + }), + toolbarRight: css({ + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: theme.spacing(1), + }), + searchInput: css({ + width: '100%', + }), + buttonSpacing: css({ + marginRight: theme.spacing(1), + }), + colorDot: css({ + display: 'inline-block', + width: '10px', + height: '10px', + borderRadius: theme.shape.radius.circle, + }), + colorDotDiff: css({ + display: 'flex', + width: '200px', + height: '12px', + color: 'white', + fontSize: 9, + lineHeight: 1.3, + fontWeight: 300, + justifyContent: 'space-between', + padding: '0 2px', + borderRadius: '2px', + }), + colorDotByValue: css({ + background: byValueGradient, + }), + colorDotByPackage: css({ + background: byPackageGradient, + }), + colorDotDiffDefault: css({ + background: diffDefaultGradient, + }), + colorDotDiffColorBlind: css({ + background: diffColorBlindGradient, + }), + table: css({ + width: '100%', + borderCollapse: 'collapse', + fontSize: theme.typography.fontSize, + color: theme.colors.text.primary, + }), + thead: css({ + backgroundColor: theme.colors.background.secondary, + position: 'sticky', + top: 0, + zIndex: 1, + }), + th: css({ + padding: '4px 6px', + textAlign: 'left', + fontWeight: theme.typography.fontWeightMedium, + borderBottom: `1px solid ${theme.colors.border.weak}`, + cursor: 'pointer', + userSelect: 'none', + '&:hover': { + backgroundColor: theme.colors.emphasize(theme.colors.background.secondary, 0.03), + }, + }), + tbody: css({ + backgroundColor: theme.colors.background.primary, + }), + tr: css({ + '&:hover': { + backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.03), + }, + }), + td: css({ + padding: '0px 6px', + borderBottom: 'none', + height: '20px', + verticalAlign: 'middle', + }), + valueCell: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px', + fontVariantNumeric: 'tabular-nums', + height: '20px', + }), + valueNumber: css({ + flex: '1 1 auto', + textAlign: 'right', + minWidth: '80px', + }), + percentNumber: css({ + flex: '0 0 70px', + textAlign: 'right', + width: '70px', + color: theme.colors.text.secondary, + }), + functionCellContainer: css({ + display: 'flex', + alignItems: 'center', + gap: '2px', + height: '20px', + lineHeight: '1', + }), + treeConnector: css({ + color: theme.colors.text.secondary, + fontSize: '16px', + lineHeight: '1', + fontFamily: 'monospace', + whiteSpace: 'pre', + display: 'inline-block', + verticalAlign: 'middle', + }), + functionButton: css({ + padding: 0, + fontSize: theme.typography.fontSize, + textAlign: 'left', + }), + sortIndicator: css({ + marginLeft: '4px', + fontSize: '10px', + }), + colorBarContainer: css({ + width: '100%', + height: '20px', + display: 'flex', + alignItems: 'center', + }), + colorBar: css({ + height: '16px', + minWidth: '2px', + borderRadius: '2px', + }), + }; +} + +export default FlameGraphCallTreeContainer; diff --git a/packages/grafana-flamegraph/src/CallTree/utils.ts b/packages/grafana-flamegraph/src/CallTree/utils.ts new file mode 100644 index 00000000000..566ffc7f811 --- /dev/null +++ b/packages/grafana-flamegraph/src/CallTree/utils.ts @@ -0,0 +1,281 @@ +import { FlameGraphDataContainer, LevelItem } from '../FlameGraph/dataTransform'; + +export interface CallTreeNode { + id: string; // Path-based ID (e.g., "0.2.1") + label: string; // Function name + self: number; // Self value + total: number; // Total value + selfPercent: number; // Self as % of root + totalPercent: number; // Total as % of root + depth: number; // Indentation level + parentId?: string; // Parent node ID + hasChildren: boolean; // Has expandable children + levelItem: LevelItem; // Reference to original data + subRows?: CallTreeNode[]; // Child nodes for react-table useExpanded + isLastChild: boolean; // Whether this is the last child of its parent + + // For diff profiles + selfRight?: number; + totalRight?: number; + selfPercentRight?: number; + totalPercentRight?: number; + diffPercent?: number; +} + +/** + * Build hierarchical call tree node from the LevelItem structure. + * Each node gets a unique ID based on its path in the tree. + * Children are stored in the subRows property for react-table useExpanded. + */ +export function buildCallTreeNode( + data: FlameGraphDataContainer, + rootItem: LevelItem, + rootTotal: number, + parentId?: string, + parentDepth: number = -1, + childIndex: number = 0 +): CallTreeNode { + const nodeId = parentId ? `${parentId}.${childIndex}` : `${childIndex}`; + const depth = parentDepth + 1; + + // Get values for current item + const itemIndex = rootItem.itemIndexes[0]; + const label = data.getLabel(itemIndex); + const self = data.getSelf(itemIndex); + const total = data.getValue(itemIndex); + const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0; + const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0; + + // For diff profiles + let selfRight: number | undefined; + let totalRight: number | undefined; + let selfPercentRight: number | undefined; + let totalPercentRight: number | undefined; + let diffPercent: number | undefined; + + if (data.isDiffFlamegraph()) { + selfRight = data.getSelfRight(itemIndex); + totalRight = data.getValueRight(itemIndex); + selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0; + totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0; + + // Calculate diff percentage (change from baseline to comparison) + if (self > 0) { + diffPercent = ((selfRight - self) / self) * 100; + } else if (selfRight > 0) { + diffPercent = Infinity; // New in comparison + } else { + diffPercent = 0; + } + } + + // Recursively build children + const subRows = + rootItem.children.length > 0 + ? rootItem.children.map((child, index) => { + const childNode = buildCallTreeNode(data, child, rootTotal, nodeId, depth, index); + // Mark if this is the last child + childNode.isLastChild = index === rootItem.children.length - 1; + return childNode; + }) + : undefined; + + const node: CallTreeNode = { + id: nodeId, + label, + self, + total, + selfPercent, + totalPercent, + depth, + parentId, + hasChildren: rootItem.children.length > 0, + levelItem: rootItem, + subRows, + isLastChild: false, // Will be set by parent + selfRight, + totalRight, + selfPercentRight, + totalPercentRight, + diffPercent, + }; + + return node; +} + +/** + * Build all call tree nodes from the root level items. + * Returns an array of root nodes, each with their children in subRows. + * This handles cases where there might be multiple root items. + */ +export function buildAllCallTreeNodes(data: FlameGraphDataContainer): CallTreeNode[] { + const levels = data.getLevels(); + const rootTotal = levels.length > 0 ? levels[0][0].value : 0; + + // Build hierarchical structure for each root item + const rootNodes = levels[0].map((rootItem, index) => buildCallTreeNode(data, rootItem, rootTotal, undefined, -1, index)); + + return rootNodes; +} + +export interface FilterResult { + visibleNodes: CallTreeNode[]; + matchingNodeIds: Set; +} + +/** + * Recursively collect all matching node IDs from the tree. + */ +function collectMatchingNodes(node: CallTreeNode, matchedLabels: Set, matchingIds: Set): boolean { + let hasMatch = false; + + // Check if current node matches + if (matchedLabels.has(node.label)) { + matchingIds.add(node.id); + hasMatch = true; + } + + // Check children + if (node.subRows) { + for (const child of node.subRows) { + if (collectMatchingNodes(child, matchedLabels, matchingIds)) { + hasMatch = true; + } + } + } + + return hasMatch; +} + +/** + * Recursively filter tree to show only matching nodes and their ancestors/descendants. + * Returns a new tree with filtered structure. + */ +function filterNode(node: CallTreeNode, matchedLabels: Set, matchingIds: Set): CallTreeNode | null { + // First, filter children recursively + let filteredSubRows: CallTreeNode[] | undefined; + if (node.subRows) { + filteredSubRows = node.subRows + .map((child) => filterNode(child, matchedLabels, matchingIds)) + .filter((child): child is CallTreeNode => child !== null); + } + + // Check if this node or any descendant matches + const nodeMatches = matchingIds.has(node.id); + const hasMatchingDescendants = filteredSubRows && filteredSubRows.length > 0; + + // Keep node if it matches or has matching descendants + if (nodeMatches || hasMatchingDescendants) { + return { + ...node, + subRows: filteredSubRows, + hasChildren: filteredSubRows ? filteredSubRows.length > 0 : false, + }; + } + + return null; +} + +/** + * Filter call tree to show only matching nodes and their ancestors. + * Non-matching ancestors are kept for context but will be visually dimmed. + */ +export function filterCallTree(nodes: CallTreeNode[], matchedLabels?: Set): FilterResult { + if (!matchedLabels || matchedLabels.size === 0) { + return { visibleNodes: nodes, matchingNodeIds: new Set() }; + } + + const matchingNodeIds = new Set(); + + // First pass: collect all matching node IDs + nodes.forEach((node) => { + collectMatchingNodes(node, matchedLabels, matchingNodeIds); + }); + + // Second pass: filter tree structure + const visibleNodes = nodes + .map((node) => filterNode(node, matchedLabels, matchingNodeIds)) + .filter((node): node is CallTreeNode => node !== null); + + return { visibleNodes, matchingNodeIds }; +} + +/** + * Recursively collect expanded state for nodes up to a certain depth. + */ +function collectExpandedByDepth( + node: CallTreeNode, + levelsToExpand: number, + expanded: Record +): void { + if (node.depth < levelsToExpand && node.hasChildren) { + expanded[node.id] = true; + } + + if (node.subRows) { + node.subRows.forEach((child) => collectExpandedByDepth(child, levelsToExpand, expanded)); + } +} + +/** + * Get initial expanded state for the tree. + * Auto-expands first N levels. + */ +export function getInitialExpandedState(nodes: CallTreeNode[], levelsToExpand: number = 2): Record { + const expanded: Record = {}; + + nodes.forEach((node) => { + collectExpandedByDepth(node, levelsToExpand, expanded); + }); + + return expanded; +} + +/** + * Recursively collect expanded state to reveal matching nodes. + */ +function collectExpandedForMatches( + node: CallTreeNode, + matchingNodeIds: Set, + expanded: Record +): boolean { + let hasMatchingDescendant = false; + + // Check if this node matches + if (matchingNodeIds.has(node.id)) { + hasMatchingDescendant = true; + } + + // Check children + if (node.subRows) { + for (const child of node.subRows) { + if (collectExpandedForMatches(child, matchingNodeIds, expanded)) { + hasMatchingDescendant = true; + } + } + } + + // Expand this node if it has matching descendants + if (hasMatchingDescendant && node.hasChildren) { + expanded[node.id] = true; + } + + return hasMatchingDescendant; +} + +/** + * Get expanded state to reveal matching nodes when filtering. + * Expands ancestors of matching nodes. + */ +export function getExpandedStateForMatches( + nodes: CallTreeNode[], + matchingNodeIds: Set +): Record { + const expanded: Record = {}; + + nodes.forEach((node) => { + collectExpandedForMatches(node, matchingNodeIds, expanded); + }); + + return expanded; +}