From aa12c6c772734ca8260732be9e4089da79888ed5 Mon Sep 17 00:00:00 2001 From: Ryan Perry Date: Thu, 7 Dec 2023 12:52:46 -0500 Subject: [PATCH] Flamegraph: Add table filtering for Flamegraph panel (#78962) --- .../src/FlameGraph/FlameGraph.test.tsx | 1 - .../src/FlameGraph/FlameGraph.tsx | 6 +-- .../src/FlameGraph/FlameGraphCanvas.tsx | 6 +-- .../src/FlameGraph/rendering.ts | 42 ++++--------------- .../src/FlameGraphContainer.test.tsx | 31 +++++++++++++- .../src/FlameGraphContainer.tsx | 32 +++++++++++++- .../src/FlameGraphHeader.tsx | 2 +- .../TopTable/FlameGraphTopTableContainer.tsx | 25 +++++++---- 8 files changed, 92 insertions(+), 53 deletions(-) diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx index fbddfada9c8..93fb6b71eb3 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.test.tsx @@ -39,7 +39,6 @@ describe('FlameGraph', () => { data={container} rangeMin={0} rangeMax={1} - search={''} setRangeMin={setRangeMin} setRangeMax={setRangeMax} onItemFocused={onItemFocused} diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx index 777fac6ecd2..6bd6bb1ab9f 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraph.tsx @@ -32,7 +32,7 @@ type Props = { data: FlameGraphDataContainer; rangeMin: number; rangeMax: number; - search: string; + matchedLabels?: Set; setRangeMin: (range: number) => void; setRangeMax: (range: number) => void; style?: React.CSSProperties; @@ -52,7 +52,7 @@ const FlameGraph = ({ data, rangeMin, rangeMax, - search, + matchedLabels, setRangeMin, setRangeMax, onItemFocused, @@ -108,7 +108,7 @@ const FlameGraph = ({ data, rangeMin, rangeMax, - search, + matchedLabels, setRangeMin, setRangeMax, onItemFocused, diff --git a/packages/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx b/packages/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx index 724376d96ce..c5b067abea2 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx +++ b/packages/grafana-flamegraph/src/FlameGraph/FlameGraphCanvas.tsx @@ -14,7 +14,7 @@ type Props = { data: FlameGraphDataContainer; rangeMin: number; rangeMax: number; - search: string; + matchedLabels: Set | undefined; setRangeMin: (range: number) => void; setRangeMax: (range: number) => void; style?: React.CSSProperties; @@ -43,7 +43,7 @@ const FlameGraphCanvas = ({ data, rangeMin, rangeMax, - search, + matchedLabels, setRangeMin, setRangeMax, onItemFocused, @@ -80,7 +80,7 @@ const FlameGraphCanvas = ({ depth, rangeMax, rangeMin, - search, + matchedLabels, textAlign, totalViewTicks, // We need this so that if we have a diff profile and are in sandwich view we still show the same diff colors. diff --git a/packages/grafana-flamegraph/src/FlameGraph/rendering.ts b/packages/grafana-flamegraph/src/FlameGraph/rendering.ts index 8c8696e3506..7e28eba7079 100644 --- a/packages/grafana-flamegraph/src/FlameGraph/rendering.ts +++ b/packages/grafana-flamegraph/src/FlameGraph/rendering.ts @@ -1,4 +1,3 @@ -import uFuzzy from '@leeoniya/ufuzzy'; import { RefObject, useCallback, useEffect, useMemo, useState } from 'react'; import color from 'tinycolor2'; @@ -22,8 +21,6 @@ import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../typ import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from './colors'; import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform'; -const ufuzzy = new uFuzzy(); - type RenderOptions = { canvasRef: RefObject; data: FlameGraphDataContainer; @@ -38,7 +35,7 @@ type RenderOptions = { rangeMin: number; rangeMax: number; - search: string; + matchedLabels: Set | undefined; textAlign: TextAlign; // Total ticks that will be used for sizing @@ -63,7 +60,7 @@ export function useFlameRender(options: RenderOptions) { wrapperWidth, rangeMin, rangeMax, - search, + matchedLabels, textAlign, totalViewTicks, totalColorTicks, @@ -72,7 +69,6 @@ export function useFlameRender(options: RenderOptions) { focusedItemData, collapsedMap, } = options; - const foundLabels = useFoundLabels(search, data); const ctx = useSetupCanvas(canvasRef, wrapperWidth, depth); const theme = useTheme2(); @@ -92,7 +88,7 @@ export function useFlameRender(options: RenderOptions) { mutedColor, rangeMin, rangeMax, - foundLabels, + matchedLabels, focusedItemData ? focusedItemData.item.level : 0 ); @@ -338,28 +334,6 @@ export function walkTree( } } -/** - * Based on the search string it does a fuzzy search over all the unique labels so we can highlight them later. - */ -function useFoundLabels(search: string | undefined, data: FlameGraphDataContainer): Set | undefined { - return useMemo(() => { - if (search) { - const foundLabels = new Set(); - let idxs = ufuzzy.filter(data.getUniqueLabels(), search); - - if (idxs) { - for (let idx of idxs) { - foundLabels.add(data.getUniqueLabels()[idx]); - } - } - - return foundLabels; - } - // In this case undefined means there was no search so no attempt to highlighting anything should be made. - return undefined; - }, [search, data]); -} - function useColorFunction( totalTicks: number, totalTicksRight: number | undefined, @@ -368,13 +342,13 @@ function useColorFunction( mutedColor: string, rangeMin: number, rangeMax: number, - foundNames: Set | undefined, + matchedLabels: Set | undefined, topLevel: number ) { return useCallback( function getColor(item: LevelItem, label: string, muted: boolean) { // If collapsed and no search we can quickly return the muted color - if (muted && !foundNames) { + if (muted && !matchedLabels) { // Collapsed are always grayed return mutedColor; } @@ -387,15 +361,15 @@ function useColorFunction( ? getBarColorByValue(item.value, totalTicks, rangeMin, rangeMax) : getBarColorByPackage(label, theme); - if (foundNames) { + if (matchedLabels) { // Means we are searching, we use color for matches and gray the rest - return foundNames.has(label) ? barColor.toHslString() : mutedColor; + return matchedLabels.has(label) ? barColor.toHslString() : mutedColor; } // Mute if we are above the focused symbol return item.level > topLevel - 1 ? barColor.toHslString() : barColor.lighten(15).toHslString(); }, - [totalTicks, totalTicksRight, colorScheme, theme, rangeMin, rangeMax, foundNames, topLevel, mutedColor] + [totalTicks, totalTicksRight, colorScheme, theme, rangeMin, rangeMax, matchedLabels, topLevel, mutedColor] ); } diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx index 7fc17fcb4b1..4a3fc56761b 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -40,9 +40,13 @@ describe('FlameGraphContainer', () => { render(); await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[0]); expect(screen.getByDisplayValue('net/http.HandlerFunc.ServeHTTP')).toBeInTheDocument(); + // Unclick the selection so that we can click something else and continue test checks + await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[0]); + await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[1]); expect(screen.getByDisplayValue('total')).toBeInTheDocument(); - await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[1]); + // after it is highlighted it will be the only (first) item in the table so [1] -> [0] + await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[0]); expect(screen.queryByDisplayValue('total')).not.toBeInTheDocument(); }); @@ -87,4 +91,27 @@ describe('FlameGraphContainer', () => { expect(screen.queryByTestId(/Both/)).toBeNull(); }); + + it('should filter table items based on search input', async () => { + // Render the FlameGraphContainer with necessary props + render(); + + // Checking for presence of this function before filter + const matchingText = 'net/http.HandlerFunc.ServeHTTP'; + const nonMatchingText = 'runtime.systemstack'; + + expect(screen.queryAllByText(matchingText).length).toBe(1); + expect(screen.queryAllByText(nonMatchingText).length).toBe(1); + + // Apply the filter + const searchInput = await screen.getByPlaceholderText('Search...'); + await userEvent.type(searchInput, 'Handler serve'); + + // We have to wait for filter to take effect + await waitFor(() => { + expect(screen.queryAllByText(nonMatchingText).length).toBe(0); + }); + // Check we didn't lose the one that should match + expect(screen.queryAllByText(matchingText).length).toBe(1); + }); }); diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx index 8cb7c89511c..df8435c0dad 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import uFuzzy from '@leeoniya/ufuzzy'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useMeasure } from 'react-use'; @@ -12,6 +13,8 @@ import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer' import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types'; +const ufuzzy = new uFuzzy(); + export type Props = { /** * DataFrame with the profile data. The dataFrame needs to have the following fields: @@ -99,6 +102,7 @@ const FlameGraphContainer = ({ }, [data, theme, disableCollapsing]); const [colorScheme, setColorScheme] = useColorScheme(dataContainer); const styles = getStyles(theme); + const matchedLabels = useLabelSearch(search, dataContainer); // If user resizes window with both as the selected view useEffect(() => { @@ -149,7 +153,7 @@ const FlameGraphContainer = ({ data={dataContainer} rangeMin={rangeMin} rangeMax={rangeMax} - search={search} + matchedLabels={matchedLabels} setRangeMin={setRangeMin} setRangeMax={setRangeMax} onItemFocused={(data) => setFocusedItemData(data)} @@ -173,6 +177,7 @@ const FlameGraphContainer = ({ data={dataContainer} onSymbolClick={onSymbolClick} search={search} + matchedLabels={matchedLabels} sandwichItem={sandwichItem} onSandwich={setSandwichItem} onSearch={setSearch} @@ -255,6 +260,31 @@ function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) { return [colorScheme, setColorScheme] as const; } +/** + * Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later. + */ +function useLabelSearch( + search: string | undefined, + data: FlameGraphDataContainer | undefined +): Set | undefined { + return useMemo(() => { + if (search && data) { + const foundLabels = new Set(); + let idxs = ufuzzy.filter(data.getUniqueLabels(), search); + + if (idxs) { + for (let idx of idxs) { + foundLabels.add(data.getUniqueLabels()[idx]); + } + } + + return foundLabels; + } + // In this case undefined means there was no search so no attempt to highlighting anything should be made. + return undefined; + }, [search, data]); +} + function getStyles(theme: GrafanaTheme2) { return { container: css({ diff --git a/packages/grafana-flamegraph/src/FlameGraphHeader.tsx b/packages/grafana-flamegraph/src/FlameGraphHeader.tsx index 7c521af4714..1e372a93660 100644 --- a/packages/grafana-flamegraph/src/FlameGraphHeader.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphHeader.tsx @@ -73,7 +73,7 @@ const FlameGraphHeader = ({ onChange={(v) => { setLocalSearch(v.currentTarget.value); }} - placeholder={'Search..'} + placeholder={'Search...'} suffix={suffix} /> diff --git a/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx b/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx index 1aeb7d30a56..936be013806 100644 --- a/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx +++ b/packages/grafana-flamegraph/src/TopTable/FlameGraphTopTableContainer.tsx @@ -29,7 +29,10 @@ import { TableData } from '../types'; type Props = { data: FlameGraphDataContainer; onSymbolClick: (symbol: string) => void; + // This is used for highlighting the search button in case there is exact match. search?: string; + // We use these to filter out rows in the table if users is doing text search. + matchedLabels?: Set; sandwichItem?: string; onSearch: (str: string) => void; onSandwich: (str?: string) => void; @@ -37,23 +40,29 @@ type Props = { }; const FlameGraphTopTableContainer = React.memo( - ({ data, onSymbolClick, search, onSearch, sandwichItem, onSandwich, onTableSort }: Props) => { + ({ data, onSymbolClick, search, matchedLabels, onSearch, sandwichItem, onSandwich, onTableSort }: Props) => { const table = useMemo(() => { // Group the data by label, we show only one row per label and sum the values // TODO: should be by filename + funcName + linenumber? - let table: { [key: string]: TableData } = {}; + let filteredTable: { [key: string]: TableData } = {}; for (let i = 0; i < data.data.length; i++) { const value = data.getValue(i); const valueRight = data.getValueRight(i); const self = data.getSelf(i); const label = data.getLabel(i); - table[label] = table[label] || {}; - table[label].self = table[label].self ? table[label].self + self : self; - table[label].total = table[label].total ? table[label].total + value : value; - table[label].totalRight = table[label].totalRight ? table[label].totalRight + valueRight : valueRight; + + // If user is doing text search we filter out labels in the same way we highlight them in flamegraph. + if (!matchedLabels || matchedLabels.has(label)) { + filteredTable[label] = filteredTable[label] || {}; + filteredTable[label].self = filteredTable[label].self ? filteredTable[label].self + self : self; + filteredTable[label].total = filteredTable[label].total ? filteredTable[label].total + value : value; + filteredTable[label].totalRight = filteredTable[label].totalRight + ? filteredTable[label].totalRight + valueRight + : valueRight; + } } - return table; - }, [data]); + return filteredTable; + }, [data, matchedLabels]); const styles = useStyles2(getStyles); const theme = useTheme2();