First iteration of call tree profile visualization

This commit is contained in:
Aleksandar Petrov
2026-01-14 09:31:39 -04:00
parent ba6a783997
commit 665a54f02f
3 changed files with 1209 additions and 0 deletions
@@ -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<typeof FlameGraphCallTreeContainer> = {
title: 'CallTree',
component: FlameGraphCallTreeContainer,
args: {
colorScheme: ColorScheme.PackageBased,
},
decorators: [
(Story) => (
<div style={{ width: '100%', height: '1000px' }}>
<Story />
</div>
),
],
};
export default meta;
export const Basic: StoryObj<typeof meta> = {
render: (args) => {
const dataContainer = new FlameGraphDataContainer(createDataFrame(data), { collapsing: true });
return (
<FlameGraphCallTreeContainer
{...args}
data={dataContainer}
onSymbolClick={(symbol) => {
console.log('Symbol clicked:', symbol);
}}
onSearch={(search) => {
console.log('Search:', search);
}}
onSandwich={(item) => {
console.log('Sandwich:', item);
}}
/>
);
},
};
@@ -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<string>;
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<ColorScheme | ColorSchemeDiff>(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<CallTreeNode>[] = useMemo(() => {
if (data.isDiffFlamegraph()) {
return [
{
Header: 'Function',
accessor: 'label',
Cell: ({ row, value, rowIndex }: { row: Row<CallTreeNode>; value: string; rowIndex?: number }) => (
<FunctionCellWithExpander
row={row as Row<CallTreeNode> & UseExpandedRowProps<CallTreeNode>}
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<CallTreeNode> }) => (
<ColorBarCell node={row.original} data={data} colorScheme={colorScheme} theme={theme} styles={styles} />
),
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 }) => (
<DiffCell value={value} colorScheme={colorScheme} theme={theme} styles={styles} />
),
sortType: 'basic',
width: 100,
},
];
} else {
return [
{
Header: 'Function',
accessor: 'label',
Cell: ({ row, value, rowIndex }: { row: Row<CallTreeNode>; value: string; rowIndex?: number }) => (
<FunctionCellWithExpander
row={row as Row<CallTreeNode> & UseExpandedRowProps<CallTreeNode>}
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<CallTreeNode> }) => (
<ColorBarCell node={row.original} data={data} colorScheme={colorScheme} theme={theme} styles={styles} />
),
minWidth: 200,
width: 200,
disableSortBy: true,
},
{
Header: 'Self',
accessor: 'self',
Cell: ({ row }: { row: Row<CallTreeNode> }) => {
const displaySelf = data.getSelfDisplay([row.original.levelItem.itemIndexes[0]]);
const formattedValue = displaySelf.suffix ? displaySelf.text + displaySelf.suffix : displaySelf.text;
return (
<div className={styles.valueCell}>
<span className={styles.valueNumber}>{formattedValue}</span>
<span className={styles.percentNumber}>{row.original.selfPercent.toFixed(2)}%</span>
</div>
);
},
sortType: 'basic',
minWidth: 120,
width: 120,
},
{
Header: 'Total',
accessor: 'total',
Cell: ({ row }: { row: Row<CallTreeNode> }) => {
const displayValue = data.valueDisplayProcessor(row.original.total);
const formattedValue = displayValue.suffix ? displayValue.text + displayValue.suffix : displayValue.text;
return (
<div className={styles.valueCell}>
<span className={styles.valueNumber}>{formattedValue}</span>
<span className={styles.percentNumber}>{row.original.totalPercent.toFixed(2)}%</span>
</div>
);
},
sortType: 'basic',
minWidth: 120,
width: 120,
},
];
}
}, [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<CallTreeNode>(
{
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 !== '' ? (
<Button
icon="times"
fill="text"
size="sm"
onClick={() => {
setLocalSearch('');
}}
>
Clear
</Button>
) : null;
return (
<div className={styles.container} data-testid="callTree">
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.toolbarLeft}>
<Input
value={localSearch}
onChange={(v) => {
setLocalSearch(v.currentTarget.value);
}}
placeholder={'Search...'}
suffix={clearSearchSuffix}
className={styles.searchInput}
/>
</div>
<div className={styles.toolbarRight}>
<ColorSchemeButton value={colorScheme} onChange={setColorScheme} isDiffMode={data.isDiffFlamegraph()} />
<ButtonGroup className={styles.buttonSpacing}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Expand all'}
onClick={() => toggleAllRowsExpanded(true)}
aria-label={'Expand all'}
icon={'angle-double-down'}
/>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Collapse all'}
onClick={() => toggleAllRowsExpanded(false)}
aria-label={'Collapse all'}
icon={'angle-double-up'}
/>
</ButtonGroup>
</div>
</div>
<AutoSizer key={tableKey} style={{ width: '100%', height: 'calc(100% - 50px)' }}>
{({ width, height }) => {
if (width < 3 || height < 3) {
return null;
}
return (
<div style={{ width, height, overflow: 'auto' }}>
<table {...getTableProps()} className={styles.table}>
<thead className={styles.thead}>
{headerGroups.map((headerGroup) => {
const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps();
return (
<tr key={key} {...headerGroupProps}>
{headerGroup.headers.map((column) => {
const { key: headerKey, ...headerProps } = column.getHeaderProps(
column.getSortByToggleProps()
);
return (
<th
key={headerKey}
{...headerProps}
className={styles.th}
style={{
...(column.width !== undefined && { width: column.width }),
textAlign: column.id === 'self' || column.id === 'total' ? 'right' : undefined,
...(column.minWidth !== undefined && { minWidth: column.minWidth })
}}
>
{column.render('Header')}
<span className={styles.sortIndicator}>
{column.isSorted ? (column.isSortedDesc ? ' ▼' : ' ▲') : ''}
</span>
</th>
);
})}
</tr>
);
})}
</thead>
<tbody {...getTableBodyProps()} className={styles.tbody}>
{rows.map((row, rowIndex) => {
prepareRow(row);
const { key, ...rowProps } = row.getRowProps();
return (
<tr key={key} {...rowProps} className={styles.tr}>
{row.cells.map((cell) => {
const { key: cellKey, ...cellProps } = cell.getCellProps();
const isValueColumn = cell.column.id === 'self' || cell.column.id === 'total';
return (
<td
key={cellKey}
{...cellProps}
className={styles.td}
style={isValueColumn ? { textAlign: 'right' } : undefined}
>
{cell.render('Cell', { rowIndex })}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}}
</AutoSizer>
</div>
);
}
);
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 = (
<Menu>
<Menu.Item label="By package name" onClick={() => props.onChange(ColorScheme.PackageBased)} />
<Menu.Item label="By value" onClick={() => props.onChange(ColorScheme.ValueBased)} />
</Menu>
);
const colorDotStyle =
{
[ColorScheme.ValueBased]: styles.colorDotByValue,
[ColorScheme.PackageBased]: styles.colorDotByPackage,
[ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind,
[ColorSchemeDiff.Default]: styles.colorDotDiffDefault,
}[props.value] || styles.colorDotByValue;
let contents = <span className={cx(styles.colorDot, colorDotStyle)} />;
if (props.isDiffMode) {
menu = (
<Menu>
<Menu.Item label="Default (green to red)" onClick={() => props.onChange(ColorSchemeDiff.Default)} />
<Menu.Item label="Color blind (blue to red)" onClick={() => props.onChange(ColorSchemeDiff.DiffColorBlind)} />
</Menu>
);
contents = (
<div className={cx(styles.colorDotDiff, colorDotStyle)}>
<div>-100% (removed)</div>
<div>0%</div>
<div>+100% (added)</div>
</div>
);
}
return (
<Dropdown overlay={menu}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Change color scheme'}
onClick={() => {}}
className={styles.buttonSpacing}
aria-label={'Change color scheme'}
>
{contents}
</Button>
</Dropdown>
);
}
// 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<CallTreeNode> & UseExpandedRowProps<CallTreeNode>;
value: string;
depth: number;
hasChildren: boolean;
rowIndex?: number;
rows: Array<Row<CallTreeNode>>;
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<string, CallTreeNode>();
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 (
<div className={styles.functionCellContainer} style={{ opacity, fontWeight }}>
{connector && <span className={styles.treeConnector}>{connector} </span>}
<Button fill="text" size="sm" onClick={handleClick} className={styles.functionButton}>
{value}
</Button>
</div>
);
}
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 (
<div className={styles.colorBarContainer}>
<div className={styles.colorBar} style={{ width: barWidth, backgroundColor: barColor }} />
</div>
);
}
function DiffCell({
value,
colorScheme,
theme,
styles,
}: {
value: number | undefined;
colorScheme: ColorScheme | ColorSchemeDiff;
theme: GrafanaTheme2;
styles: any;
}) {
if (value === undefined) {
return <span>-</span>;
}
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 <span style={{ color, fontWeight: 'bold' }}>{displayValue}</span>;
}
// 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;
@@ -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<string>;
}
/**
* Recursively collect all matching node IDs from the tree.
*/
function collectMatchingNodes(node: CallTreeNode, matchedLabels: Set<string>, matchingIds: Set<string>): 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<string>, matchingIds: Set<string>): 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<string>): FilterResult {
if (!matchedLabels || matchedLabels.size === 0) {
return { visibleNodes: nodes, matchingNodeIds: new Set() };
}
const matchingNodeIds = new Set<string>();
// 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<string, boolean>
): 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<string, boolean> {
const expanded: Record<string, boolean> = {};
nodes.forEach((node) => {
collectExpandedByDepth(node, levelsToExpand, expanded);
});
return expanded;
}
/**
* Recursively collect expanded state to reveal matching nodes.
*/
function collectExpandedForMatches(
node: CallTreeNode,
matchingNodeIds: Set<string>,
expanded: Record<string, boolean>
): 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<string>
): Record<string, boolean> {
const expanded: Record<string, boolean> = {};
nodes.forEach((node) => {
collectExpandedForMatches(node, matchingNodeIds, expanded);
});
return expanded;
}