First iteration of call tree profile visualization
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user