From f974cb12b565f1eabd8208395391b1c71bcf96af Mon Sep 17 00:00:00 2001 From: Bryan Huhta <32787160+bryanhuhta@users.noreply.github.com> Date: Mon, 16 Jun 2025 00:33:53 -0700 Subject: [PATCH] FlameGraph: Add support for regex search patterns and multiple search terms (#106347) * "or" search terms with commas * Add regex support to search bar * Don't try match empty search terms * Fix lint error --- .../src/FlameGraphContainer.test.tsx | 109 +++++++++++++++++- .../src/FlameGraphContainer.tsx | 69 +++++++++-- 2 files changed, 160 insertions(+), 18 deletions(-) diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx index 97a434172fd..94fcfa71a1c 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.test.tsx @@ -4,8 +4,9 @@ import { useRef, useCallback } from 'react'; import { createDataFrame, createTheme } from '@grafana/data'; +import { FlameGraphDataContainer } from './FlameGraph/dataTransform'; import { data } from './FlameGraph/testData/dataNestedSet'; -import FlameGraphContainer from './FlameGraphContainer'; +import FlameGraphContainer, { labelSearch } from './FlameGraphContainer'; import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants'; jest.mock('react-use', () => ({ @@ -99,21 +100,117 @@ describe('FlameGraphContainer', () => { render(); // Checking for presence of this function before filter - const matchingText = 'net/http.HandlerFunc.ServeHTTP'; + const matchingText1 = 'net/http.HandlerFunc.ServeHTTP'; + const matchingText2 = 'runtime.gcBgMarkWorker'; const nonMatchingText = 'runtime.systemstack'; - expect(screen.queryAllByText(matchingText).length).toBe(1); + expect(screen.queryAllByText(matchingText1).length).toBe(1); + expect(screen.queryAllByText(matchingText2).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'); + const searchInput = screen.getByPlaceholderText('Search...'); + await userEvent.type(searchInput, 'Handler serve,gcBgMarkWorker'); // 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); + expect(screen.queryAllByText(matchingText1).length).toBe(1); + expect(screen.queryAllByText(matchingText2).length).toBe(1); + }); +}); + +describe('labelSearch', () => { + let container: FlameGraphDataContainer; + + beforeEach(() => { + const df = createDataFrame(data); + df.meta = { + custom: { + ProfileTypeID: 'cpu:foo:bar', + }, + }; + + container = new FlameGraphDataContainer(df, { collapsing: false }); + }); + + describe('fuzzy', () => { + it('single term', () => { + const search = 'test pkg'; + let found = labelSearch(search, container); + expect(found.size).toBe(45); + }); + + it('multiple terms', () => { + const search = 'test pkg,compress'; + let found = labelSearch(search, container); + expect(found.size).toBe(107); + }); + + it('falls back to fuzzy with malformed regex', () => { + const search = 'deduplicatingSlice[.'; + let found = labelSearch(search, container); + expect(found.size).toBe(1); + }); + + it('no results', () => { + const search = 'term_not_found'; + let found = labelSearch(search, container); + expect(found.size).toBe(0); + }); + }); + + describe('regex', () => { + it('single pattern', () => { + const term = '\\d$'; + let found = labelSearch(term, container); + expect(found.size).toBe(61); + }); + + it('multiple patterns', () => { + const term = '\\d$,^go'; + let found = labelSearch(term, container); + expect(found.size).toBe(62); + }); + + it('no results', () => { + const term = 'pattern_not_found'; + let found = labelSearch(term, container); + expect(found.size).toBe(0); + }); + }); + + describe('fuzzy and regex', () => { + it('regex found, fuzzy found', () => { + const term = '\\d$,test pkg'; + let found = labelSearch(term, container); + expect(found.size).toBe(98); + }); + + it('regex not found, fuzzy found', () => { + const term = 'not_found_suffix$,test pkg'; + let found = labelSearch(term, container); + expect(found.size).toBe(45); + }); + + it('regex found, fuzzy not found', () => { + const term = '\\d$,not_found_fuzzy'; + let found = labelSearch(term, container); + expect(found.size).toBe(61); + }); + + it('regex not found, fuzzy not found', () => { + const term = 'not_found_suffix$,not_found_fuzzy'; + let found = labelSearch(term, container); + expect(found.size).toBe(0); + }); + + it('does not match empty terms', () => { + const search = ',,,,,'; + let found = labelSearch(search, container); + expect(found.size).toBe(0); + }); }); }); diff --git a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx index 4d23da838e2..706702142db 100644 --- a/packages/grafana-flamegraph/src/FlameGraphContainer.tsx +++ b/packages/grafana-flamegraph/src/FlameGraphContainer.tsx @@ -317,26 +317,71 @@ function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) { /** * Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later. */ -function useLabelSearch( +export 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 (!search || !data) { + // In this case undefined means there was no search so no attempt to + // highlighting anything should be made. + return undefined; + } - if (idxs) { - for (let idx of idxs) { - foundLabels.add(data.getUniqueLabels()[idx]); - } + return labelSearch(search, data); + }, [search, data]); +} + +export function labelSearch(search: string, data: FlameGraphDataContainer): Set { + const foundLabels = new Set(); + const terms = search.split(','); + + const regexFilter = (labels: string[], pattern: string): boolean => { + let regex: RegExp; + try { + regex = new RegExp(pattern); + } catch (e) { + return false; + } + + let foundMatch = false; + for (let label of labels) { + if (!regex.test(label)) { + continue; } - return foundLabels; + foundLabels.add(label); + foundMatch = true; } - // In this case undefined means there was no search so no attempt to highlighting anything should be made. - return undefined; - }, [search, data]); + return foundMatch; + }; + + const fuzzyFilter = (labels: string[], term: string): boolean => { + let idxs = ufuzzy.filter(labels, term); + if (!idxs) { + return false; + } + + let foundMatch = false; + for (let idx of idxs) { + foundLabels.add(labels[idx]); + foundMatch = true; + } + return foundMatch; + }; + + for (let term of terms) { + if (!term) { + continue; + } + + const found = regexFilter(data.getUniqueLabels(), term); + if (!found) { + fuzzyFilter(data.getUniqueLabels(), term); + } + } + + return foundLabels; } function getStyles(theme: GrafanaTheme2) {