Flamegraph: Add table filtering for Flamegraph panel (#78962)

This commit is contained in:
Ryan Perry 2023-12-07 12:52:46 -05:00 committed by GitHub
parent 1c53561521
commit aa12c6c772
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 92 additions and 53 deletions

View File

@ -39,7 +39,6 @@ describe('FlameGraph', () => {
data={container}
rangeMin={0}
rangeMax={1}
search={''}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
onItemFocused={onItemFocused}

View File

@ -32,7 +32,7 @@ type Props = {
data: FlameGraphDataContainer;
rangeMin: number;
rangeMax: number;
search: string;
matchedLabels?: Set<string>;
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,

View File

@ -14,7 +14,7 @@ type Props = {
data: FlameGraphDataContainer;
rangeMin: number;
rangeMax: number;
search: string;
matchedLabels: Set<string> | 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.

View File

@ -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<HTMLCanvasElement>;
data: FlameGraphDataContainer;
@ -38,7 +35,7 @@ type RenderOptions = {
rangeMin: number;
rangeMax: number;
search: string;
matchedLabels: Set<string> | 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<string> | undefined {
return useMemo(() => {
if (search) {
const foundLabels = new Set<string>();
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<string> | undefined,
matchedLabels: Set<string> | 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]
);
}

View File

@ -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(<FlameGraphContainerWithProps />);
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(<FlameGraphContainerWithProps />);
// 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);
});
});

View File

@ -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<string> | undefined {
return useMemo(() => {
if (search && data) {
const foundLabels = new Set<string>();
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({

View File

@ -73,7 +73,7 @@ const FlameGraphHeader = ({
onChange={(v) => {
setLocalSearch(v.currentTarget.value);
}}
placeholder={'Search..'}
placeholder={'Search...'}
suffix={suffix}
/>
</div>

View File

@ -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<string>;
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();