Add support to show callers in call tree
This commit is contained in:
@@ -36,9 +36,6 @@ export const Basic: StoryObj<typeof meta> = {
|
||||
onSymbolClick={(symbol) => {
|
||||
console.log('Symbol clicked:', symbol);
|
||||
}}
|
||||
onSearch={(search) => {
|
||||
console.log('Search:', search);
|
||||
}}
|
||||
onSandwich={(item) => {
|
||||
console.log('Sandwich:', item);
|
||||
}}
|
||||
|
||||
@@ -1,147 +1,236 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { useTable, useSortBy, useExpanded, Column, Row, UseExpandedRowProps } from 'react-table';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { useDebounce, usePrevious } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, Dropdown, Input, Menu, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { Button, Input, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from '../FlameGraph/colors';
|
||||
import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from '../FlameGraph/colors';
|
||||
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
|
||||
import { labelSearch } from '../FlameGraphContainer';
|
||||
import { ColorScheme, ColorSchemeDiff } from '../types';
|
||||
import {
|
||||
buildAllCallTreeNodes,
|
||||
buildCallersTreeFromLevels,
|
||||
CallTreeNode,
|
||||
filterCallTree,
|
||||
getExpandedStateForMatches,
|
||||
getInitialExpandedState,
|
||||
} from './utils';
|
||||
|
||||
type Props = {
|
||||
data: FlameGraphDataContainer;
|
||||
onSymbolClick: (symbol: string) => void;
|
||||
search?: string;
|
||||
matchedLabels?: Set<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) => {
|
||||
({ data, onSymbolClick, sandwichItem, onSandwich, onTableSort, colorScheme: initialColorScheme }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const theme = useTheme2();
|
||||
|
||||
// Color scheme state
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme | ColorSchemeDiff>(initialColorScheme);
|
||||
// Use package-based color scheme by default
|
||||
const colorScheme = data.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased;
|
||||
|
||||
// Focus state - track which node is focused
|
||||
const [focusedNodeId, setFocusedNodeId] = useState<string | undefined>(undefined);
|
||||
|
||||
// Update color scheme when prop changes
|
||||
useEffect(() => {
|
||||
setColorScheme(initialColorScheme);
|
||||
}, [initialColorScheme]);
|
||||
// Callers state - track which function's callers we're showing
|
||||
const [callersNodeLabel, setCallersNodeLabel] = useState<string | undefined>(undefined);
|
||||
|
||||
// Search with debouncing
|
||||
const [localSearch, setLocalSearch] = useSearchInput(search || '', onSearch);
|
||||
|
||||
// Get matched labels from search
|
||||
const searchMatchedLabels = useMemo(() => {
|
||||
if (!localSearch) {
|
||||
return undefined;
|
||||
// Wrapper functions for mutual exclusivity
|
||||
const handleSetFocusMode = (nodeIdOrLabel: string | undefined, isLabel: boolean = false) => {
|
||||
if (nodeIdOrLabel === undefined) {
|
||||
setFocusedNodeId(undefined);
|
||||
} else if (isLabel) {
|
||||
// When switching from callers mode, we need to find the node by label in the normal tree
|
||||
// We'll set a special marker that the useMemo will use to find the node
|
||||
setFocusedNodeId(`label:${nodeIdOrLabel}`);
|
||||
} else {
|
||||
setFocusedNodeId(nodeIdOrLabel);
|
||||
}
|
||||
return labelSearch(localSearch, data);
|
||||
}, [localSearch, data]);
|
||||
|
||||
// Use search-based matched labels if available, otherwise use prop
|
||||
const effectiveMatchedLabels = searchMatchedLabels || matchedLabels;
|
||||
if (nodeIdOrLabel !== undefined) {
|
||||
setCallersNodeLabel(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// Build and filter nodes
|
||||
const { nodes, matchingIds, focusedNode } = useMemo(() => {
|
||||
const handleSetCallersMode = (label: string | undefined) => {
|
||||
setCallersNodeLabel(label);
|
||||
if (label !== undefined) {
|
||||
setFocusedNodeId(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// Build nodes
|
||||
const { nodes, focusedNode, callersNode } = useMemo(() => {
|
||||
const allNodes = buildAllCallTreeNodes(data);
|
||||
|
||||
// If there's a focused node, find it and use its subtree
|
||||
// If there's a focused node, find it and use its subtree with parent
|
||||
let nodesToUse = allNodes;
|
||||
let focused: CallTreeNode | undefined;
|
||||
let callersTargetNode: CallTreeNode | undefined;
|
||||
|
||||
if (focusedNodeId) {
|
||||
// Check if we're searching by label (when switching from callers mode)
|
||||
const isLabelSearch = focusedNodeId.startsWith('label:');
|
||||
const searchKey = isLabelSearch ? focusedNodeId.substring(6) : focusedNodeId;
|
||||
|
||||
// Find the focused node in the tree
|
||||
const findNode = (nodes: CallTreeNode[], id: string): CallTreeNode | undefined => {
|
||||
const findNode = (nodes: CallTreeNode[], searchKey: string, byLabel: boolean): CallTreeNode | undefined => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) {
|
||||
if (byLabel ? node.label === searchKey : node.id === searchKey) {
|
||||
return node;
|
||||
}
|
||||
if (node.subRows) {
|
||||
const found = findNode(node.subRows, id);
|
||||
const found = findNode(node.subRows, searchKey, byLabel);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
focused = findNode(allNodes, focusedNodeId);
|
||||
focused = findNode(allNodes, searchKey, isLabelSearch);
|
||||
if (focused) {
|
||||
// Use the focused node as the root
|
||||
nodesToUse = [focused];
|
||||
// If we searched by label, update the focusedNodeId to use the actual ID
|
||||
if (isLabelSearch) {
|
||||
// Update state to use the actual ID for future operations
|
||||
// We do this asynchronously to avoid updating state during render
|
||||
setTimeout(() => setFocusedNodeId(focused!.id), 0);
|
||||
}
|
||||
|
||||
// If the focused node has a parent, show parent with focused node as only child
|
||||
if (focused.parentId) {
|
||||
const parent = findNode(allNodes, focused.parentId, false);
|
||||
if (parent) {
|
||||
// Create a modified parent that only shows the focused node as its child
|
||||
const modifiedParent: CallTreeNode = {
|
||||
...parent,
|
||||
subRows: [focused],
|
||||
hasChildren: true,
|
||||
childCount: 1,
|
||||
};
|
||||
nodesToUse = [modifiedParent];
|
||||
} else {
|
||||
// Parent not found, just use focused node
|
||||
nodesToUse = [focused];
|
||||
}
|
||||
} else {
|
||||
// No parent, use the focused node as the root
|
||||
nodesToUse = [focused];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { visibleNodes, matchingNodeIds } = filterCallTree(nodesToUse, effectiveMatchedLabels);
|
||||
return { nodes: visibleNodes, matchingIds: matchingNodeIds, focusedNode: focused };
|
||||
}, [data, effectiveMatchedLabels, focusedNodeId]);
|
||||
// If there's a callers mode active, build the inverted tree
|
||||
if (callersNodeLabel) {
|
||||
const [callers, _] = data.getSandwichLevels(callersNodeLabel);
|
||||
|
||||
// Calculate expanded state based on search
|
||||
const calculatedExpanded = useMemo(() => {
|
||||
const baseExpanded = getInitialExpandedState(nodes, 2);
|
||||
console.log('Calculated base expanded:', {
|
||||
numBaseExpanded: Object.keys(baseExpanded).filter(k => baseExpanded[k]).length,
|
||||
totalNodes: nodes.length,
|
||||
firstNode: nodes[0]?.id,
|
||||
expandedKeys: Object.keys(baseExpanded).filter(k => baseExpanded[k]),
|
||||
});
|
||||
if (callers.length > 0 && callers[0].length > 0) {
|
||||
const levels = data.getLevels();
|
||||
const rootTotal = levels.length > 0 ? levels[0][0].value : 0;
|
||||
|
||||
if (effectiveMatchedLabels && effectiveMatchedLabels.size > 0) {
|
||||
const matchExpanded = getExpandedStateForMatches(nodes, matchingIds);
|
||||
console.log('Adding match expansion:', {
|
||||
numMatchExpanded: Object.keys(matchExpanded).filter(k => matchExpanded[k]).length,
|
||||
matchExpandedKeys: Object.keys(matchExpanded).filter(k => matchExpanded[k]),
|
||||
matchingIds: Array.from(matchingIds),
|
||||
hasSearch: true,
|
||||
});
|
||||
return { ...baseExpanded, ...matchExpanded };
|
||||
// Build inverted tree directly with target as root and callers as children
|
||||
const { tree, targetNode } = buildCallersTreeFromLevels(callers, callersNodeLabel, data, rootTotal);
|
||||
|
||||
nodesToUse = tree;
|
||||
callersTargetNode = targetNode;
|
||||
} else {
|
||||
// No callers found - show empty tree
|
||||
nodesToUse = [];
|
||||
callersTargetNode = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: nodesToUse, focusedNode: focused, callersNode: callersTargetNode };
|
||||
}, [data, focusedNodeId, callersNodeLabel]);
|
||||
|
||||
// Calculate expanded state
|
||||
const calculatedExpanded = useMemo(() => {
|
||||
const baseExpanded = getInitialExpandedState(nodes, 1);
|
||||
|
||||
// If there's a focused node, expand to show its children
|
||||
if (focusedNodeId && nodes.length > 0) {
|
||||
const rootNode = nodes[0];
|
||||
|
||||
// Check if focusedNodeId is a label-based search
|
||||
const isLabelSearch = focusedNodeId.startsWith('label:');
|
||||
const searchLabel = isLabelSearch ? focusedNodeId.substring(6) : undefined;
|
||||
|
||||
// Always expand the root to show the focused node
|
||||
if (rootNode.hasChildren) {
|
||||
baseExpanded['0'] = true;
|
||||
}
|
||||
|
||||
// If the root is the parent (not the focused node itself), also expand the focused node
|
||||
const isRootTheFocusedNode = isLabelSearch
|
||||
? rootNode.label === searchLabel
|
||||
: rootNode.id === focusedNodeId;
|
||||
|
||||
if (!isRootTheFocusedNode && rootNode.hasChildren) {
|
||||
// The focused node is at "0.0" (first child of parent)
|
||||
baseExpanded['0.0'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If in callers mode, expand to show the target node
|
||||
if (callersNodeLabel && callersNode && nodes.length > 0) {
|
||||
// Find path from root to target node and expand all nodes along the path
|
||||
const expandPathToNode = (nodes: CallTreeNode[], targetId: string): boolean => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === targetId) {
|
||||
// Found the target - don't expand it yet, but confirm path
|
||||
return true;
|
||||
}
|
||||
if (node.subRows && node.hasChildren) {
|
||||
// Check if target is in this subtree
|
||||
const foundInSubtree = expandPathToNode(node.subRows, targetId);
|
||||
if (foundInSubtree) {
|
||||
// Target is in this subtree, so expand this node
|
||||
baseExpanded[node.id] = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Expand path to target
|
||||
expandPathToNode(nodes, callersNode.id);
|
||||
|
||||
// Also expand the target node itself to show its immediate callers (children in this view)
|
||||
if (callersNode.hasChildren) {
|
||||
baseExpanded[callersNode.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return baseExpanded;
|
||||
}, [nodes, effectiveMatchedLabels, matchingIds]);
|
||||
}, [nodes, focusedNodeId, callersNodeLabel, callersNode]);
|
||||
|
||||
// Create a key that changes when expansion should reset, forcing table remount
|
||||
const tableKey = useMemo(() => {
|
||||
// Include matchingIds in the key so table remounts when search results change
|
||||
const expandedKeys = Object.keys(calculatedExpanded).filter(k => calculatedExpanded[k]).sort().join(',');
|
||||
const focusPart = focusedNodeId ? `-focus-${focusedNodeId}` : '';
|
||||
return `table-${expandedKeys}${focusPart}`;
|
||||
}, [calculatedExpanded, focusedNodeId]);
|
||||
const callersPart = callersNodeLabel ? `-callers-${callersNodeLabel}` : '';
|
||||
return `table-${expandedKeys}${focusPart}${callersPart}`;
|
||||
}, [calculatedExpanded, focusedNodeId, callersNodeLabel]);
|
||||
|
||||
// Define columns
|
||||
const columns: Column<CallTreeNode>[] = useMemo(() => {
|
||||
const columns = useMemo<Column<CallTreeNode>[]>(() => {
|
||||
if (data.isDiffFlamegraph()) {
|
||||
return [
|
||||
{
|
||||
Header: '',
|
||||
id: 'actions',
|
||||
Cell: ({ row }: { row: Row<CallTreeNode> & UseExpandedRowProps<CallTreeNode> }) => (
|
||||
Cell: ({ row }: any) => (
|
||||
<ActionsCell
|
||||
row={row}
|
||||
onFocus={(nodeId) => {
|
||||
setFocusedNodeId(nodeId);
|
||||
setLocalSearch('');
|
||||
}}
|
||||
isFocusedRoot={row.id === '0'}
|
||||
onFocus={handleSetFocusMode}
|
||||
onShowCallers={handleSetCallersMode}
|
||||
focusedNodeId={focusedNodeId}
|
||||
callersNodeLabel={callersNodeLabel}
|
||||
styles={styles}
|
||||
/>
|
||||
),
|
||||
@@ -160,8 +249,6 @@ const FlameGraphCallTreeContainer = memo(
|
||||
hasChildren={row.original.hasChildren}
|
||||
rowIndex={rowIndex}
|
||||
rows={tableInstance.rows}
|
||||
isMatching={matchingIds.has(row.original.id)}
|
||||
hasFilter={effectiveMatchedLabels !== undefined && effectiveMatchedLabels.size > 0}
|
||||
onSymbolClick={onSymbolClick}
|
||||
styles={styles}
|
||||
allNodes={nodes}
|
||||
@@ -174,7 +261,7 @@ const FlameGraphCallTreeContainer = memo(
|
||||
Header: '',
|
||||
id: 'colorBar',
|
||||
Cell: ({ row }: { row: Row<CallTreeNode> }) => (
|
||||
<ColorBarCell node={row.original} data={data} colorScheme={colorScheme} theme={theme} styles={styles} />
|
||||
<ColorBarCell node={row.original} data={data} colorScheme={colorScheme} theme={theme} styles={styles} focusedNode={focusedNode} callersNode={callersNode} />
|
||||
),
|
||||
minWidth: 200,
|
||||
width: 200,
|
||||
@@ -209,14 +296,13 @@ const FlameGraphCallTreeContainer = memo(
|
||||
{
|
||||
Header: '',
|
||||
id: 'actions',
|
||||
Cell: ({ row }: { row: Row<CallTreeNode> & UseExpandedRowProps<CallTreeNode> }) => (
|
||||
Cell: ({ row }: any) => (
|
||||
<ActionsCell
|
||||
row={row}
|
||||
onFocus={(nodeId) => {
|
||||
setFocusedNodeId(nodeId);
|
||||
setLocalSearch('');
|
||||
}}
|
||||
isFocusedRoot={row.id === '0'}
|
||||
onFocus={handleSetFocusMode}
|
||||
onShowCallers={handleSetCallersMode}
|
||||
focusedNodeId={focusedNodeId}
|
||||
callersNodeLabel={callersNodeLabel}
|
||||
styles={styles}
|
||||
/>
|
||||
),
|
||||
@@ -235,8 +321,6 @@ const FlameGraphCallTreeContainer = memo(
|
||||
hasChildren={row.original.hasChildren}
|
||||
rowIndex={rowIndex}
|
||||
rows={tableInstance.rows}
|
||||
isMatching={matchingIds.has(row.original.id)}
|
||||
hasFilter={effectiveMatchedLabels !== undefined && effectiveMatchedLabels.size > 0}
|
||||
onSymbolClick={onSymbolClick}
|
||||
styles={styles}
|
||||
allNodes={nodes}
|
||||
@@ -249,7 +333,7 @@ const FlameGraphCallTreeContainer = memo(
|
||||
Header: '',
|
||||
id: 'colorBar',
|
||||
Cell: ({ row }: { row: Row<CallTreeNode> }) => (
|
||||
<ColorBarCell node={row.original} data={data} colorScheme={colorScheme} theme={theme} styles={styles} />
|
||||
<ColorBarCell node={row.original} data={data} colorScheme={colorScheme} theme={theme} styles={styles} focusedNode={focusedNode} callersNode={callersNode} />
|
||||
),
|
||||
minWidth: 200,
|
||||
width: 200,
|
||||
@@ -291,7 +375,7 @@ const FlameGraphCallTreeContainer = memo(
|
||||
},
|
||||
];
|
||||
}
|
||||
}, [data, effectiveMatchedLabels, matchingIds, onSymbolClick, colorScheme, theme, styles, setLocalSearch]);
|
||||
}, [data, onSymbolClick, colorScheme, theme, styles, focusedNode, callersNode, callersNodeLabel]);
|
||||
|
||||
// toggleRowExpanded is used in the Cell renderers but doesn't need to be in the dependencies
|
||||
// because it's accessed at render time, not definition time
|
||||
@@ -314,35 +398,7 @@ const FlameGraphCallTreeContainer = memo(
|
||||
useExpanded
|
||||
);
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow, state } = tableInstance;
|
||||
|
||||
console.log('Table state:', {
|
||||
visibleRows: rows.length,
|
||||
expandedState: Object.keys(state.expanded).filter(k => state.expanded[k]).length,
|
||||
expandedIds: Object.keys(state.expanded).filter(k => state.expanded[k]),
|
||||
firstFewRows: rows.slice(0, 5).map(r => ({
|
||||
id: r.id,
|
||||
originalId: r.original.id,
|
||||
label: r.original.label,
|
||||
isExpanded: (r as any).isExpanded,
|
||||
hasSubRows: !!r.original.subRows
|
||||
})),
|
||||
rootNodeSubRows: nodes[0]?.subRows?.length,
|
||||
});
|
||||
|
||||
const clearSearchSuffix =
|
||||
localSearch !== '' ? (
|
||||
<Button
|
||||
icon="times"
|
||||
fill="text"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLocalSearch('');
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
) : null;
|
||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = tableInstance;
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="callTree">
|
||||
@@ -350,33 +406,44 @@ const FlameGraphCallTreeContainer = memo(
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarLeft}>
|
||||
<Input
|
||||
value={localSearch}
|
||||
onChange={(v) => {
|
||||
setLocalSearch(v.currentTarget.value);
|
||||
}}
|
||||
value={''}
|
||||
disabled
|
||||
placeholder={'Search...'}
|
||||
suffix={clearSearchSuffix}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{focusedNode && (
|
||||
<div className={styles.focusedItem}>
|
||||
<Button icon="eye" fill="text" size="sm" disabled aria-label="Focused" />
|
||||
<span className={styles.focusedItemLabel}>{focusedNode.label}</span>
|
||||
<Button icon="compress-arrows" fill="text" size="sm" disabled aria-label="Callees view" />
|
||||
<span className={styles.focusedItemLabel}>Showing callees of: {focusedNode.label}</span>
|
||||
<Button
|
||||
icon="times"
|
||||
fill="text"
|
||||
size="sm"
|
||||
onClick={() => setFocusedNodeId(undefined)}
|
||||
tooltip="Clear focus"
|
||||
aria-label="Clear focus"
|
||||
onClick={() => handleSetFocusMode(undefined)}
|
||||
tooltip="Clear callees view"
|
||||
aria-label="Clear callees view"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callersNode && (
|
||||
<div className={styles.callersItem}>
|
||||
<Button icon="expand-arrows-alt" fill="text" size="sm" disabled aria-label="Callers view" />
|
||||
<span className={styles.callersItemLabel}>Showing callers of: {callersNodeLabel}</span>
|
||||
<Button
|
||||
icon="times"
|
||||
fill="text"
|
||||
size="sm"
|
||||
onClick={() => handleSetCallersMode(undefined)}
|
||||
tooltip="Clear callers view"
|
||||
aria-label="Clear callers view"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.toolbarRight}>
|
||||
<ColorSchemeButton value={colorScheme} onChange={setColorScheme} isDiffMode={data.isDiffFlamegraph()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -424,8 +491,18 @@ const FlameGraphCallTreeContainer = memo(
|
||||
{rows.map((row, rowIndex) => {
|
||||
prepareRow(row);
|
||||
const { key, ...rowProps } = row.getRowProps();
|
||||
const isFocusedRow = row.original.id === focusedNodeId;
|
||||
const isCallersTargetRow = callersNodeLabel && row.original.label === callersNodeLabel;
|
||||
return (
|
||||
<tr key={key} {...rowProps} className={styles.tr}>
|
||||
<tr
|
||||
key={key}
|
||||
{...rowProps}
|
||||
className={cx(
|
||||
styles.tr,
|
||||
(isFocusedRow || (focusedNodeId?.startsWith('label:') && focusedNodeId.substring(6) === row.original.label)) && styles.focusedRow,
|
||||
isCallersTargetRow && styles.callersTargetRow
|
||||
)}
|
||||
>
|
||||
{row.cells.map((cell) => {
|
||||
const { key: cellKey, ...cellProps } = cell.getCellProps();
|
||||
const isValueColumn = cell.column.id === 'self' || cell.column.id === 'total';
|
||||
@@ -457,67 +534,6 @@ const FlameGraphCallTreeContainer = memo(
|
||||
|
||||
FlameGraphCallTreeContainer.displayName = 'FlameGraphCallTreeContainer';
|
||||
|
||||
// ColorSchemeButton component
|
||||
type ColorSchemeButtonProps = {
|
||||
value: ColorScheme | ColorSchemeDiff;
|
||||
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
|
||||
isDiffMode: boolean;
|
||||
};
|
||||
|
||||
function ColorSchemeButton(props: ColorSchemeButtonProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
let menu = (
|
||||
<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,
|
||||
@@ -559,30 +575,82 @@ function getRowBackgroundColor(
|
||||
function ActionsCell({
|
||||
row,
|
||||
onFocus,
|
||||
isFocusedRoot,
|
||||
onShowCallers,
|
||||
focusedNodeId,
|
||||
callersNodeLabel,
|
||||
styles,
|
||||
}: {
|
||||
row: Row<CallTreeNode> & UseExpandedRowProps<CallTreeNode>;
|
||||
onFocus: (nodeId: string) => void;
|
||||
isFocusedRoot: boolean;
|
||||
onFocus: (nodeIdOrLabel: string, isLabel?: boolean) => void;
|
||||
onShowCallers: (label: string) => void;
|
||||
focusedNodeId: string | undefined;
|
||||
callersNodeLabel: string | undefined;
|
||||
styles: any;
|
||||
}) {
|
||||
const hasChildren = row.original.hasChildren;
|
||||
const isTheFocusedNode = row.original.id === focusedNodeId ||
|
||||
(focusedNodeId?.startsWith('label:') && focusedNodeId.substring(6) === row.original.label);
|
||||
const isTheCallersTarget = row.original.label === callersNodeLabel;
|
||||
const inCallersMode = callersNodeLabel !== undefined;
|
||||
const inFocusMode = focusedNodeId !== undefined;
|
||||
const isRootNode = row.original.depth === 0 && !row.original.parentId;
|
||||
|
||||
// Show focus button if:
|
||||
// - Node has children AND
|
||||
// - Node is not the currently focused node AND
|
||||
// - If it's the root node, only show when in focus mode (as parent)
|
||||
// Allow switching from callers mode to focus mode
|
||||
const shouldShowFocusButton = hasChildren && !isTheFocusedNode && !(isRootNode && !inFocusMode);
|
||||
|
||||
// Show callers button if:
|
||||
// - Node is not the current callers target AND
|
||||
// - Node is not the root node
|
||||
// Allow switching from focus mode to callers mode
|
||||
const shouldShowCallersButton = !isTheCallersTarget && !isRootNode;
|
||||
|
||||
return (
|
||||
<div className={styles.actionsCell}>
|
||||
{!isFocusedRoot && (
|
||||
<Button
|
||||
icon="eye"
|
||||
fill="text"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFocus(row.original.id);
|
||||
}}
|
||||
tooltip="Focus on this subtree"
|
||||
aria-label="Focus"
|
||||
className={styles.actionButton}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.actionButtonSlot}>
|
||||
{shouldShowFocusButton ? (
|
||||
<Button
|
||||
icon="compress-arrows"
|
||||
fill="text"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// When in callers mode, switch by label; otherwise use ID
|
||||
if (inCallersMode) {
|
||||
onFocus(row.original.label, true);
|
||||
} else {
|
||||
onFocus(row.original.id, false);
|
||||
}
|
||||
}}
|
||||
tooltip="Show callees of this function"
|
||||
aria-label="Show callees"
|
||||
className={styles.actionButton}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.actionButtonPlaceholder} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.actionButtonSlot}>
|
||||
{shouldShowCallersButton ? (
|
||||
<Button
|
||||
icon="expand-arrows-alt"
|
||||
fill="text"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShowCallers(row.original.label);
|
||||
}}
|
||||
tooltip="Show callers of this function"
|
||||
aria-label="Show callers"
|
||||
className={styles.actionButton}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.actionButtonPlaceholder} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -594,8 +662,6 @@ function FunctionCellWithExpander({
|
||||
hasChildren,
|
||||
rowIndex,
|
||||
rows,
|
||||
isMatching,
|
||||
hasFilter,
|
||||
onSymbolClick,
|
||||
styles,
|
||||
allNodes,
|
||||
@@ -606,15 +672,10 @@ function FunctionCellWithExpander({
|
||||
hasChildren: boolean;
|
||||
rowIndex?: number;
|
||||
rows: Array<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();
|
||||
@@ -719,7 +780,7 @@ function FunctionCellWithExpander({
|
||||
const connector = buildTreeConnector();
|
||||
|
||||
return (
|
||||
<div className={styles.functionCellContainer} style={{ opacity, fontWeight }}>
|
||||
<div className={styles.functionCellContainer}>
|
||||
{connector && <span className={styles.treeConnector}>{connector} </span>}
|
||||
<Button fill="text" size="sm" onClick={handleClick} className={styles.functionButton}>
|
||||
{value}
|
||||
@@ -740,15 +801,37 @@ function ColorBarCell({
|
||||
colorScheme,
|
||||
theme,
|
||||
styles,
|
||||
focusedNode,
|
||||
callersNode,
|
||||
}: {
|
||||
node: CallTreeNode;
|
||||
data: FlameGraphDataContainer;
|
||||
colorScheme: ColorScheme | ColorSchemeDiff;
|
||||
theme: GrafanaTheme2;
|
||||
styles: any;
|
||||
focusedNode?: CallTreeNode;
|
||||
callersNode?: CallTreeNode;
|
||||
}) {
|
||||
const barColor = getRowBackgroundColor(node, data, colorScheme, theme);
|
||||
const barWidth = `${Math.min(node.totalPercent, 100)}%`;
|
||||
|
||||
// Calculate bar width
|
||||
let barWidth: string;
|
||||
|
||||
if (focusedNode) {
|
||||
// In focused state - scale bars relative to focused node
|
||||
if (node.id === focusedNode.parentId) {
|
||||
// This is the parent node - skip the bar
|
||||
barWidth = '0%';
|
||||
} else {
|
||||
// Scale relative to the focused node's total
|
||||
const relativePercent = focusedNode.total > 0 ? (node.total / focusedNode.total) * 100 : 0;
|
||||
barWidth = `${Math.min(relativePercent, 100)}%`;
|
||||
}
|
||||
} else {
|
||||
// Not in focused state (normal mode or callers mode) - use the original percentage
|
||||
// In callers mode, we keep original scaling to show relative impact of different caller paths
|
||||
barWidth = `${Math.min(node.totalPercent, 100)}%`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.colorBarContainer}>
|
||||
@@ -789,33 +872,6 @@ function DiffCell({
|
||||
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) {
|
||||
@@ -851,39 +907,6 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
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',
|
||||
@@ -915,6 +938,22 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.03),
|
||||
},
|
||||
}),
|
||||
focusedRow: css({
|
||||
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.08),
|
||||
borderLeft: `3px solid ${theme.colors.primary.main}`,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.1),
|
||||
},
|
||||
}),
|
||||
callersTargetRow: css({
|
||||
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.08),
|
||||
borderLeft: `3px solid ${theme.colors.info.main}`,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.1),
|
||||
},
|
||||
}),
|
||||
td: css({
|
||||
padding: '0px 6px',
|
||||
borderBottom: 'none',
|
||||
@@ -985,8 +1024,21 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
actionsCell: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: theme.spacing(0.5),
|
||||
height: '20px',
|
||||
minWidth: '60px', // Fixed width to ensure consistent alignment
|
||||
}),
|
||||
actionButtonSlot: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '24px', // Fixed width for each button slot
|
||||
height: '20px',
|
||||
}),
|
||||
actionButtonPlaceholder: css({
|
||||
width: '24px',
|
||||
height: '20px',
|
||||
}),
|
||||
actionsColumnCell: css({
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
@@ -1017,6 +1069,20 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
color: theme.colors.text.primary,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
}),
|
||||
callersItem: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`,
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
border: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
callersItemLabel: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
color: theme.colors.text.primary,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -126,86 +126,151 @@ export function buildAllCallTreeNodes(data: FlameGraphDataContainer): CallTreeNo
|
||||
return rootNodes;
|
||||
}
|
||||
|
||||
export interface FilterResult {
|
||||
visibleNodes: CallTreeNode[];
|
||||
matchingNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all matching node IDs from the tree.
|
||||
* Build call tree nodes from an array of levels (from mergeParentSubtrees).
|
||||
* This is used for the callers view where we get LevelItem[][] from getSandwichLevels.
|
||||
* Unlike buildCallTreeNode which recursively processes children, this function
|
||||
* processes pre-organized levels and builds the hierarchy from them.
|
||||
*/
|
||||
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;
|
||||
export function buildCallTreeFromLevels(
|
||||
levels: LevelItem[][],
|
||||
data: FlameGraphDataContainer,
|
||||
rootTotal: number
|
||||
): CallTreeNode[] {
|
||||
if (levels.length === 0 || levels[0].length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check children
|
||||
if (node.subRows) {
|
||||
for (const child of node.subRows) {
|
||||
if (collectMatchingNodes(child, matchedLabels, matchingIds)) {
|
||||
hasMatch = true;
|
||||
// Map to track LevelItem -> CallTreeNode for building relationships
|
||||
const levelItemToNode = new Map<LevelItem, CallTreeNode>();
|
||||
|
||||
// Process each level and build nodes
|
||||
levels.forEach((level, levelIndex) => {
|
||||
level.forEach((levelItem, itemIndex) => {
|
||||
// Get values from data
|
||||
const itemDataIndex = levelItem.itemIndexes[0];
|
||||
const label = data.getLabel(itemDataIndex);
|
||||
const self = data.getSelf(itemDataIndex);
|
||||
const total = data.getValue(itemDataIndex);
|
||||
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(itemDataIndex);
|
||||
totalRight = data.getValueRight(itemDataIndex);
|
||||
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
|
||||
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
|
||||
|
||||
// Calculate diff percentage
|
||||
if (self > 0) {
|
||||
diffPercent = ((selfRight - self) / self) * 100;
|
||||
} else if (selfRight > 0) {
|
||||
diffPercent = Infinity;
|
||||
} else {
|
||||
diffPercent = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasMatch;
|
||||
}
|
||||
// Determine parent (if exists)
|
||||
let parentId: string | undefined;
|
||||
let depth = levelIndex;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
if (levelItem.parents && levelItem.parents.length > 0) {
|
||||
const parentNode = levelItemToNode.get(levelItem.parents[0]);
|
||||
if (parentNode) {
|
||||
parentId = parentNode.id;
|
||||
depth = parentNode.depth + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this node or any descendant matches
|
||||
const nodeMatches = matchingIds.has(node.id);
|
||||
const hasMatchingDescendants = filteredSubRows && filteredSubRows.length > 0;
|
||||
// Generate path-based ID
|
||||
// For root nodes, use index at level 0
|
||||
// For child nodes, append index to parent ID
|
||||
let nodeId: string;
|
||||
if (!parentId) {
|
||||
nodeId = `${itemIndex}`;
|
||||
} else {
|
||||
// Find index among siblings
|
||||
const parent = levelItemToNode.get(levelItem.parents![0]);
|
||||
const siblingIndex = parent?.subRows?.length || 0;
|
||||
nodeId = `${parentId}.${siblingIndex}`;
|
||||
}
|
||||
|
||||
// Keep node if it matches or has matching descendants
|
||||
if (nodeMatches || hasMatchingDescendants) {
|
||||
return {
|
||||
...node,
|
||||
subRows: filteredSubRows,
|
||||
hasChildren: filteredSubRows ? filteredSubRows.length > 0 : false,
|
||||
};
|
||||
}
|
||||
// Create the node (without children initially)
|
||||
const node: CallTreeNode = {
|
||||
id: nodeId,
|
||||
label,
|
||||
self,
|
||||
total,
|
||||
selfPercent,
|
||||
totalPercent,
|
||||
depth,
|
||||
parentId,
|
||||
hasChildren: levelItem.children.length > 0,
|
||||
childCount: levelItem.children.length,
|
||||
subtreeSize: 0, // Will be calculated later
|
||||
levelItem,
|
||||
subRows: undefined,
|
||||
isLastChild: false,
|
||||
selfRight,
|
||||
totalRight,
|
||||
selfPercentRight,
|
||||
totalPercentRight,
|
||||
diffPercent,
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
// Add to map
|
||||
levelItemToNode.set(levelItem, node);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
// Add as child to parent
|
||||
if (levelItem.parents && levelItem.parents.length > 0) {
|
||||
const parentNode = levelItemToNode.get(levelItem.parents[0]);
|
||||
if (parentNode) {
|
||||
if (!parentNode.subRows) {
|
||||
parentNode.subRows = [];
|
||||
}
|
||||
parentNode.subRows.push(node);
|
||||
// Mark if this is the last child
|
||||
const isLastChild = parentNode.subRows.length === parentNode.childCount;
|
||||
node.isLastChild = isLastChild;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Second pass: filter tree structure
|
||||
const visibleNodes = nodes
|
||||
.map((node) => filterNode(node, matchedLabels, matchingNodeIds))
|
||||
.filter((node): node is CallTreeNode => node !== null);
|
||||
// Calculate subtreeSize for all nodes (bottom-up)
|
||||
const calculateSubtreeSize = (node: CallTreeNode): number => {
|
||||
if (!node.subRows || node.subRows.length === 0) {
|
||||
node.subtreeSize = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return { visibleNodes, matchingNodeIds };
|
||||
const size = node.subRows.reduce((sum, child) => {
|
||||
return sum + calculateSubtreeSize(child) + 1;
|
||||
}, 0);
|
||||
|
||||
node.subtreeSize = size;
|
||||
return size;
|
||||
};
|
||||
|
||||
// Collect root nodes (level 0)
|
||||
const rootNodes: CallTreeNode[] = [];
|
||||
levels[0].forEach((levelItem) => {
|
||||
const node = levelItemToNode.get(levelItem);
|
||||
if (node) {
|
||||
calculateSubtreeSize(node);
|
||||
rootNodes.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return rootNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,50 +305,276 @@ export function getInitialExpandedState(nodes: CallTreeNode[], levelsToExpand: n
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect expanded state to reveal matching nodes.
|
||||
* Restructure the callers tree to show a specific target node at the root.
|
||||
* In the callers view, we want to show the target function with its callers as children.
|
||||
* This function finds the target node and collects all paths that lead to it,
|
||||
* then restructures them so the target is at the root.
|
||||
*/
|
||||
function collectExpandedForMatches(
|
||||
node: CallTreeNode,
|
||||
matchingNodeIds: Set<string>,
|
||||
expanded: Record<string, boolean>
|
||||
): boolean {
|
||||
let hasMatchingDescendant = false;
|
||||
export function restructureCallersTree(
|
||||
nodes: CallTreeNode[],
|
||||
targetLabel: string
|
||||
): { restructuredTree: CallTreeNode[]; targetNode: CallTreeNode | undefined } {
|
||||
// First, find all paths from root to target node
|
||||
const findPathsToTarget = (
|
||||
nodes: CallTreeNode[],
|
||||
targetLabel: string,
|
||||
currentPath: CallTreeNode[] = []
|
||||
): CallTreeNode[][] => {
|
||||
const paths: CallTreeNode[][] = [];
|
||||
|
||||
// Check if this node matches
|
||||
if (matchingNodeIds.has(node.id)) {
|
||||
hasMatchingDescendant = true;
|
||||
for (const node of nodes) {
|
||||
const newPath = [...currentPath, node];
|
||||
|
||||
if (node.label === targetLabel) {
|
||||
// Found a path to the target
|
||||
paths.push(newPath);
|
||||
}
|
||||
|
||||
if (node.subRows && node.subRows.length > 0) {
|
||||
// Continue searching in children
|
||||
const childPaths = findPathsToTarget(node.subRows, targetLabel, newPath);
|
||||
paths.push(...childPaths);
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
const paths = findPathsToTarget(nodes, targetLabel);
|
||||
|
||||
if (paths.length === 0) {
|
||||
// Target not found, return original tree
|
||||
return { restructuredTree: nodes, targetNode: undefined };
|
||||
}
|
||||
|
||||
// Check children
|
||||
if (node.subRows) {
|
||||
for (const child of node.subRows) {
|
||||
if (collectExpandedForMatches(child, matchingNodeIds, expanded)) {
|
||||
hasMatchingDescendant = true;
|
||||
// Get the target node from the first path (they should all have the same target node)
|
||||
const targetNode = paths[0][paths[0].length - 1];
|
||||
|
||||
// Now restructure: create a new tree with target at root
|
||||
// Each path to the target becomes a branch under the target
|
||||
// For example, if we have: root -> A -> B -> target
|
||||
// We want: target -> B -> A -> root (inverted)
|
||||
|
||||
const buildInvertedChildren = (paths: CallTreeNode[][]): CallTreeNode[] => {
|
||||
// Group paths by their immediate caller (the node right before target)
|
||||
const callerGroups = new Map<string, CallTreeNode[][]>();
|
||||
|
||||
for (const path of paths) {
|
||||
if (path.length <= 1) {
|
||||
// Path is just the target node itself, no callers
|
||||
continue;
|
||||
}
|
||||
|
||||
// The immediate caller is the node right before the target
|
||||
const immediateCaller = path[path.length - 2];
|
||||
const callerKey = immediateCaller.label;
|
||||
|
||||
if (!callerGroups.has(callerKey)) {
|
||||
callerGroups.set(callerKey, []);
|
||||
}
|
||||
callerGroups.get(callerKey)!.push(path);
|
||||
}
|
||||
|
||||
// Build nodes for each immediate caller
|
||||
const callerNodes: CallTreeNode[] = [];
|
||||
let callerIndex = 0;
|
||||
|
||||
for (const [, callerPaths] of callerGroups.entries()) {
|
||||
// Get the immediate caller node from one of the paths
|
||||
const immediateCallerNode = callerPaths[0][callerPaths[0].length - 2];
|
||||
|
||||
// For this caller, recursively build its callers (from the remaining path)
|
||||
const remainingPaths = callerPaths.map((path) => path.slice(0, -1)); // Remove target from paths
|
||||
const grandCallers = buildInvertedChildren(remainingPaths);
|
||||
|
||||
// Create a new node for this caller as a child of the target
|
||||
const newCallerId = `0.${callerIndex}`;
|
||||
const callerNode: CallTreeNode = {
|
||||
...immediateCallerNode,
|
||||
id: newCallerId,
|
||||
depth: 1,
|
||||
parentId: '0',
|
||||
subRows: grandCallers.length > 0 ? grandCallers : undefined,
|
||||
hasChildren: grandCallers.length > 0,
|
||||
childCount: grandCallers.length,
|
||||
isLastChild: callerIndex === callerGroups.size - 1,
|
||||
};
|
||||
|
||||
// Update IDs of grandCallers
|
||||
if (grandCallers.length > 0) {
|
||||
grandCallers.forEach((grandCaller, idx) => {
|
||||
updateNodeIds(grandCaller, newCallerId, idx);
|
||||
});
|
||||
}
|
||||
|
||||
callerNodes.push(callerNode);
|
||||
callerIndex++;
|
||||
}
|
||||
|
||||
return callerNodes;
|
||||
};
|
||||
|
||||
// Helper to recursively update node IDs
|
||||
const updateNodeIds = (node: CallTreeNode, parentId: string, index: number) => {
|
||||
node.id = `${parentId}.${index}`;
|
||||
node.parentId = parentId;
|
||||
node.depth = parentId.split('.').length;
|
||||
|
||||
if (node.subRows) {
|
||||
node.subRows.forEach((child, idx) => {
|
||||
updateNodeIds(child, node.id, idx);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Build the inverted children for the target
|
||||
const invertedChildren = buildInvertedChildren(paths);
|
||||
|
||||
// Create the restructured target node as root
|
||||
const restructuredTarget: CallTreeNode = {
|
||||
...targetNode,
|
||||
id: '0',
|
||||
depth: 0,
|
||||
parentId: undefined,
|
||||
subRows: invertedChildren.length > 0 ? invertedChildren : undefined,
|
||||
hasChildren: invertedChildren.length > 0,
|
||||
childCount: invertedChildren.length,
|
||||
subtreeSize: invertedChildren.reduce((sum, child) => sum + child.subtreeSize + 1, 0),
|
||||
isLastChild: false,
|
||||
};
|
||||
|
||||
return { restructuredTree: [restructuredTarget], targetNode: restructuredTarget };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a callers tree directly from sandwich levels data.
|
||||
* This creates an inverted tree where the target function is at the root
|
||||
* and its callers are shown as children.
|
||||
*/
|
||||
export function buildCallersTreeFromLevels(
|
||||
levels: LevelItem[][],
|
||||
targetLabel: string,
|
||||
data: FlameGraphDataContainer,
|
||||
rootTotal: number
|
||||
): { tree: CallTreeNode[]; targetNode: CallTreeNode | undefined } {
|
||||
if (levels.length === 0) {
|
||||
return { tree: [], targetNode: undefined };
|
||||
}
|
||||
|
||||
// Find the target node in the levels
|
||||
let targetLevelIndex = -1;
|
||||
let targetItem: LevelItem | undefined;
|
||||
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
for (const item of levels[i]) {
|
||||
const label = data.getLabel(item.itemIndexes[0]);
|
||||
if (label === targetLabel) {
|
||||
targetLevelIndex = i;
|
||||
targetItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetItem) break;
|
||||
}
|
||||
|
||||
if (!targetItem || targetLevelIndex === -1) {
|
||||
// Target not found
|
||||
return { tree: [], targetNode: undefined };
|
||||
}
|
||||
|
||||
// Create a map from LevelItem to all items that reference it as a parent
|
||||
const childrenMap = new Map<LevelItem, LevelItem[]>();
|
||||
|
||||
for (const level of levels) {
|
||||
for (const item of level) {
|
||||
if (item.parents) {
|
||||
for (const parent of item.parents) {
|
||||
if (!childrenMap.has(parent)) {
|
||||
childrenMap.set(parent, []);
|
||||
}
|
||||
childrenMap.get(parent)!.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expand this node if it has matching descendants
|
||||
if (hasMatchingDescendant && node.hasChildren) {
|
||||
expanded[node.id] = true;
|
||||
}
|
||||
// Build the inverted tree recursively
|
||||
// For callers view: the target is root, and parents become children
|
||||
const buildInvertedNode = (
|
||||
item: LevelItem,
|
||||
nodeId: string,
|
||||
depth: number,
|
||||
parentId: string | undefined
|
||||
): CallTreeNode => {
|
||||
const itemIdx = item.itemIndexes[0];
|
||||
const label = data.getLabel(itemIdx);
|
||||
const self = data.getSelf(itemIdx);
|
||||
const total = data.getValue(itemIdx);
|
||||
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
|
||||
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
|
||||
|
||||
return hasMatchingDescendant;
|
||||
}
|
||||
// For diff profiles
|
||||
let selfRight: number | undefined;
|
||||
let totalRight: number | undefined;
|
||||
let selfPercentRight: number | undefined;
|
||||
let totalPercentRight: number | undefined;
|
||||
let diffPercent: number | undefined;
|
||||
|
||||
/**
|
||||
* 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> = {};
|
||||
if (data.isDiffFlamegraph()) {
|
||||
selfRight = data.getSelfRight(itemIdx);
|
||||
totalRight = data.getValueRight(itemIdx);
|
||||
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
|
||||
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
|
||||
|
||||
nodes.forEach((node) => {
|
||||
collectExpandedForMatches(node, matchingNodeIds, expanded);
|
||||
});
|
||||
if (self > 0) {
|
||||
diffPercent = ((selfRight - self) / self) * 100;
|
||||
} else if (selfRight > 0) {
|
||||
diffPercent = Infinity;
|
||||
} else {
|
||||
diffPercent = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return expanded;
|
||||
// In the inverted tree, parents become children (callers)
|
||||
const callers = item.parents || [];
|
||||
const subRows =
|
||||
callers.length > 0
|
||||
? callers.map((caller, idx) => {
|
||||
const callerId = `${nodeId}.${idx}`;
|
||||
const callerNode = buildInvertedNode(caller, callerId, depth + 1, nodeId);
|
||||
callerNode.isLastChild = idx === callers.length - 1;
|
||||
return callerNode;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const childCount = callers.length;
|
||||
const subtreeSize = subRows ? subRows.reduce((sum, child) => sum + child.subtreeSize + 1, 0) : 0;
|
||||
|
||||
return {
|
||||
id: nodeId,
|
||||
label,
|
||||
self,
|
||||
total,
|
||||
selfPercent,
|
||||
totalPercent,
|
||||
depth,
|
||||
parentId,
|
||||
hasChildren: callers.length > 0,
|
||||
childCount,
|
||||
subtreeSize,
|
||||
levelItem: item,
|
||||
subRows,
|
||||
isLastChild: false,
|
||||
selfRight,
|
||||
totalRight,
|
||||
selfPercentRight,
|
||||
totalPercentRight,
|
||||
diffPercent,
|
||||
};
|
||||
};
|
||||
|
||||
// Build tree with target as root
|
||||
const targetNode = buildInvertedNode(targetItem, '0', 0, undefined);
|
||||
|
||||
return { tree: [targetNode], targetNode };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,40 @@
|
||||
import { Decorator } from '@storybook/react';
|
||||
import { useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { getThemeById, ThemeContext } from '@grafana/data';
|
||||
import { GlobalStyles } from '@grafana/ui';
|
||||
import { createTheme, getThemeById, ThemeContext } from '@grafana/data';
|
||||
import { GlobalStyles, PortalContainer } from '@grafana/ui';
|
||||
|
||||
interface ThemeableStoryProps {
|
||||
themeId: string;
|
||||
themeId?: string;
|
||||
}
|
||||
const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<ThemeableStoryProps>) => {
|
||||
const theme = getThemeById(themeId);
|
||||
// Always ensure we have a valid theme
|
||||
const theme = React.useMemo(() => {
|
||||
const id = themeId || 'dark';
|
||||
let resolvedTheme = getThemeById(id);
|
||||
|
||||
// If getThemeById returns undefined, create a default theme
|
||||
if (!resolvedTheme) {
|
||||
console.warn(`Theme '${id}' not found, using default theme`);
|
||||
resolvedTheme = createTheme({ colors: { mode: id === 'light' ? 'light' : 'dark' } });
|
||||
}
|
||||
|
||||
console.log('withTheme: resolved theme', { id, hasTheme: !!resolvedTheme, hasSpacing: !!resolvedTheme?.spacing });
|
||||
return resolvedTheme;
|
||||
}, [themeId]);
|
||||
|
||||
// Apply theme to document root for Portals
|
||||
useEffect(() => {
|
||||
if (!theme) return;
|
||||
|
||||
document.body.style.setProperty('--theme-background', theme.colors.background.primary);
|
||||
}, [theme]);
|
||||
|
||||
if (!theme) {
|
||||
console.error('withTheme: No theme available!');
|
||||
return null;
|
||||
}
|
||||
|
||||
const css = `
|
||||
#storybook-root {
|
||||
@@ -23,6 +49,7 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
|
||||
return (
|
||||
<ThemeContext.Provider value={theme}>
|
||||
<GlobalStyles />
|
||||
<PortalContainer />
|
||||
|
||||
<style>{css}</style>
|
||||
{children}
|
||||
@@ -33,4 +60,4 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
|
||||
export const withTheme =
|
||||
(): Decorator =>
|
||||
// eslint-disable-next-line react/display-name
|
||||
(story, context) => <ThemeableStory themeId={context.globals.theme}>{story()}</ThemeableStory>;
|
||||
(story, context) => <ThemeableStory themeId={context.globals?.theme}>{story()}</ThemeableStory>;
|
||||
|
||||
Reference in New Issue
Block a user