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
This commit is contained in:
Bryan Huhta
2025-06-16 09:33:53 +02:00
committed by GitHub
parent 4b9cf4eb35
commit f974cb12b5
2 changed files with 160 additions and 18 deletions
@@ -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(<FlameGraphContainerWithProps />);
// 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);
});
});
});
@@ -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<string> | undefined {
return useMemo(() => {
if (search && data) {
const foundLabels = new Set<string>();
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<string> {
const foundLabels = new Set<string>();
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) {