diff --git a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx index a10ff787e87..f20070efc2b 100644 --- a/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx +++ b/packages/grafana-flamegraph/src/CallTree/FlameGraphCallTreeContainer.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { memo, useMemo, useState } from 'react'; +import { memo, useMemo, useState, useRef, useEffect } from 'react'; import { useTable, useSortBy, useExpanded, Column, Row, UseExpandedRowProps } from 'react-table'; import AutoSizer from 'react-virtualized-auto-sizer'; @@ -30,6 +30,9 @@ const FlameGraphCallTreeContainer = memo( const styles = useStyles2(getStyles); const theme = useTheme2(); + // Ref for the matched search row to enable auto-scrolling + const searchMatchRowRef = useRef(null); + // Use package-based color scheme by default const colorScheme = data.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased; @@ -39,6 +42,11 @@ const FlameGraphCallTreeContainer = memo( // Callers state - track which function's callers we're showing const [callersNodeLabel, setCallersNodeLabel] = useState(undefined); + // Search state + const [searchQuery, setSearchQuery] = useState(''); + const [currentMatchIndex, setCurrentMatchIndex] = useState(0); + const [searchError, setSearchError] = useState(undefined); + // Wrapper functions for mutual exclusivity const handleSetFocusMode = (nodeIdOrLabel: string | undefined, isLabel: boolean = false) => { if (nodeIdOrLabel === undefined) { @@ -63,7 +71,7 @@ const FlameGraphCallTreeContainer = memo( } }; - // Build nodes + // Build nodes - dependencies include currentSearchMatchId to force rebuild when search match changes const { nodes, focusedNode, callersNode } = useMemo(() => { const allNodes = buildAllCallTreeNodes(data); @@ -146,10 +154,140 @@ const FlameGraphCallTreeContainer = memo( return { nodes: nodesToUse, focusedNode: focused, callersNode: callersTargetNode }; }, [data, focusedNodeId, callersNodeLabel]); + // Search function - finds matching nodes in the tree + const searchNodes = useMemo(() => { + if (!searchQuery.trim()) { + return []; + } + + const MAX_MATCHES = 50; + const matches: Array<{ id: string; total: number }> = []; + + // Determine if the query is a regex pattern (contains regex special chars) + const regexChars = /[.*+?^${}()|[\]\\]/; + let isRegexQuery = regexChars.test(searchQuery); + let searchRegex: RegExp | null = null; + + if (isRegexQuery) { + try { + searchRegex = new RegExp(searchQuery, 'i'); + setSearchError(undefined); + } catch (e) { + setSearchError('Invalid regex pattern'); + return []; + } + } + + // Recursive search through nodes + const search = (nodesToSearch: CallTreeNode[]) => { + for (const node of nodesToSearch) { + if (matches.length >= MAX_MATCHES) { + break; + } + + // Match against node label + let isMatch = false; + if (searchRegex) { + isMatch = searchRegex.test(node.label); + } else { + isMatch = node.label.toLowerCase().includes(searchQuery.toLowerCase()); + } + + if (isMatch) { + matches.push({ id: node.id, total: node.total }); + } + + // Recursively search children + if (node.subRows && matches.length < MAX_MATCHES) { + search(node.subRows); + } + } + }; + + search(nodes); + + // Sort by total time descending (most significant first) + matches.sort((a, b) => b.total - a.total); + + const matchIds = matches.map(m => m.id); + + setSearchError(undefined); + return matchIds; + }, [searchQuery, nodes]); + + // Reset current match index when search results change + const searchResultKey = searchNodes.join(','); + useEffect(() => { + setCurrentMatchIndex(searchNodes.length > 0 ? 0 : -1); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchResultKey]); + + // Navigation functions for search results + const navigateToNextMatch = () => { + if (searchNodes.length > 0) { + setCurrentMatchIndex((prev) => (prev + 1) % searchNodes.length); + } + }; + + const navigateToPrevMatch = () => { + if (searchNodes.length > 0) { + setCurrentMatchIndex((prev) => (prev - 1 + searchNodes.length) % searchNodes.length); + } + }; + + const clearSearch = () => { + setSearchQuery(''); + setCurrentMatchIndex(-1); + setSearchError(undefined); + }; + + // Get current search match node ID + const currentSearchMatchId = useMemo(() => { + if (searchNodes.length > 0 && currentMatchIndex >= 0 && currentMatchIndex < searchNodes.length) { + return searchNodes[currentMatchIndex]; + } + return undefined; + }, [searchNodes, currentMatchIndex]); + + // Auto-scroll to the matched row when current match changes + useEffect(() => { + if (searchMatchRowRef.current) { + searchMatchRowRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + }, [currentMatchIndex, currentSearchMatchId]); + // Calculate expanded state const calculatedExpanded = useMemo(() => { const baseExpanded = getInitialExpandedState(nodes, 1); + // If there's a search match, expand path to show it + if (currentSearchMatchId) { + // Expand all nodes along the path to the current match + const expandPathToNode = (nodes: CallTreeNode[], targetId: string): boolean => { + for (const node of nodes) { + if (node.id === targetId) { + // Found the target + 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; + }; + + expandPathToNode(nodes, currentSearchMatchId); + } + // If there's a focused node, expand to show its children if (focusedNodeId && nodes.length > 0) { const rootNode = nodes[0]; @@ -206,16 +344,7 @@ const FlameGraphCallTreeContainer = memo( } return baseExpanded; - }, [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}` : ''; - const callersPart = callersNodeLabel ? `-callers-${callersNodeLabel}` : ''; - return `table-${expandedKeys}${focusPart}${callersPart}`; - }, [calculatedExpanded, focusedNodeId, callersNodeLabel]); + }, [nodes, focusedNodeId, callersNodeLabel, callersNode, currentSearchMatchId]); // Define columns const columns = useMemo[]>(() => { @@ -380,18 +509,25 @@ const FlameGraphCallTreeContainer = memo( // 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 + // Create a stable nodes reference that changes when search match changes + // This triggers autoResetExpanded in the table + const tableNodes = useMemo(() => { + // Return a shallow copy to force reference change + return [...nodes]; + }, [nodes, currentSearchMatchId]); + // Setup table instance with expand and sort - // Using initialState - expansion resets when data changes so new initialState takes effect + // Using autoResetExpanded: true so expansion resets when data changes const tableInstance = useTable( { columns, - data: nodes, + data: tableNodes, getSubRows: (row) => row.subRows || [], initialState: { - expanded: calculatedExpanded, sortBy: [{ id: 'total', desc: true }], + expanded: calculatedExpanded, }, - autoResetExpanded: true, // Reset expansion when data changes so initialState applies + autoResetExpanded: true, // Reset to initialState when data changes autoResetSortBy: false, }, useSortBy, @@ -405,12 +541,56 @@ const FlameGraphCallTreeContainer = memo( {/* Toolbar */}
- +
+ setSearchQuery(e.currentTarget.value)} + placeholder="Search..." + className={styles.searchInput} + suffix={ + searchQuery && ( +
+ )} + {searchQuery && searchNodes.length === 0 && !searchError && ( + No matches found + )} + {searchError && ( + {searchError} + )} +
{focusedNode && ( @@ -447,7 +627,7 @@ const FlameGraphCallTreeContainer = memo( - + {({ width, height }) => { if (width < 3 || height < 3) { return null; @@ -493,14 +673,17 @@ const FlameGraphCallTreeContainer = memo( const { key, ...rowProps } = row.getRowProps(); const isFocusedRow = row.original.id === focusedNodeId; const isCallersTargetRow = callersNodeLabel && row.original.label === callersNodeLabel; + const isSearchMatchRow = currentSearchMatchId && row.original.id === currentSearchMatchId; return ( {row.cells.map((cell) => { @@ -907,6 +1090,33 @@ function getStyles(theme: GrafanaTheme2) { searchInput: css({ width: '100%', }), + searchContainer: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + width: '100%', + flexWrap: 'wrap', + }), + searchNavigation: css({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + padding: `0 ${theme.spacing(1)}`, + }), + searchCounter: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + whiteSpace: 'nowrap', + }), + searchNoResults: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + fontStyle: 'italic', + }), + searchError: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.error.text, + }), table: css({ width: '100%', borderCollapse: 'collapse', @@ -954,6 +1164,14 @@ function getStyles(theme: GrafanaTheme2) { backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.1), }, }), + searchMatchRow: css({ + backgroundColor: theme.colors.warning.transparent, + borderLeft: `3px solid ${theme.colors.warning.main}`, + fontWeight: theme.typography.fontWeightMedium, + '&:hover': { + backgroundColor: theme.colors.emphasize(theme.colors.warning.transparent, 0.1), + }, + }), td: css({ padding: '0px 6px', borderBottom: 'none',