FlameGraph: Add column in table with buttons to filter and sandwich a symbol (#71773)

This commit is contained in:
Andrej Ocenas
2023-07-18 09:54:25 +02:00
committed by GitHub
parent 1d6b9625b8
commit c2778325f6
3 changed files with 241 additions and 84 deletions
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import { DataFrame, CoreApp, GrafanaTheme2 } from '@grafana/data';
@@ -54,11 +54,11 @@ const FlameGraphContainer = (props: Props) => {
}
}, [selectedView, setSelectedView, containerWidth]);
function resetFocus() {
const resetFocus = useCallback(() => {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
}
}, [setFocusedItemData, setRangeMax, setRangeMin]);
function resetSandwich() {
setSandwichItem(undefined);
@@ -67,7 +67,23 @@ const FlameGraphContainer = (props: Props) => {
useEffect(() => {
resetFocus();
resetSandwich();
}, [props.data]);
}, [props.data, resetFocus]);
const onSymbolClick = useCallback(
(symbol: string) => {
if (search === symbol) {
setSearch('');
} else {
reportInteraction('grafana_flamegraph_table_item_selected', {
app: props.app,
grafana_version: config.buildInfo.version,
});
setSearch(symbol);
resetFocus();
}
},
[setSearch, resetFocus, props.app, search]
);
return (
<>
@@ -96,18 +112,12 @@ const FlameGraphContainer = (props: Props) => {
<FlameGraphTopTableContainer
data={dataContainer}
app={props.app}
onSymbolClick={(symbol) => {
if (search === symbol) {
setSearch('');
} else {
reportInteraction('grafana_flamegraph_table_item_selected', {
app: props.app,
grafana_version: config.buildInfo.version,
});
setSearch(symbol);
}
}}
onSymbolClick={onSymbolClick}
height={selectedView === SelectedView.TopTable ? 600 : undefined}
search={search}
sandwichItem={sandwichItem}
onSandwich={setSandwichItem}
onSearch={setSearch}
/>
)}
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import userEvents from '@testing-library/user-event';
import React from 'react';
import { CoreApp, createDataFrame } from '@grafana/data';
@@ -9,39 +10,67 @@ import { data } from '../FlameGraph/testData/dataNestedSet';
import FlameGraphTopTableContainer from './FlameGraphTopTableContainer';
describe('FlameGraphTopTableContainer', () => {
const FlameGraphTopTableContainerWithProps = () => {
const setup = () => {
const flameGraphData = createDataFrame(data);
const container = new FlameGraphDataContainer(flameGraphData);
const onSearch = jest.fn();
const onSandwich = jest.fn();
return <FlameGraphTopTableContainer data={container} app={CoreApp.Explore} onSymbolClick={jest.fn()} />;
const renderResult = render(
<FlameGraphTopTableContainer
data={container}
app={CoreApp.Explore}
onSymbolClick={jest.fn()}
onSearch={onSearch}
onSandwich={onSandwich}
/>
);
return { renderResult, mocks: { onSearch, onSandwich } };
};
it('should render without error', async () => {
expect(() => render(<FlameGraphTopTableContainerWithProps />)).not.toThrow();
});
it('should render correctly', async () => {
// Needed for AutoSizer to work in test
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 500 });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 500 });
render(<FlameGraphTopTableContainerWithProps />);
setup();
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(16);
const columnHeaders = screen.getAllByRole('columnheader');
expect(columnHeaders).toHaveLength(3);
expect(columnHeaders[0].textContent).toEqual('Symbol');
expect(columnHeaders[1].textContent).toEqual('Self');
expect(columnHeaders[2].textContent).toEqual('Total');
expect(columnHeaders).toHaveLength(4);
expect(columnHeaders[1].textContent).toEqual('Symbol');
expect(columnHeaders[2].textContent).toEqual('Self');
expect(columnHeaders[3].textContent).toEqual('Total');
const cells = screen.getAllByRole('cell');
expect(cells).toHaveLength(45); // 16 rows
expect(cells[0].textContent).toEqual('net/http.HandlerFunc.ServeHTTP');
expect(cells[1].textContent).toEqual('31.7 K');
expect(cells[2].textContent).toEqual('31.7 Bil');
expect(cells[24].textContent).toEqual('test/pkg/create.(*create).initServer.func2.1');
expect(cells[25].textContent).toEqual('5.58 K');
expect(cells[26].textContent).toEqual('5.58 Bil');
expect(cells).toHaveLength(60); // 16 rows
expect(cells[1].textContent).toEqual('net/http.HandlerFunc.ServeHTTP');
expect(cells[2].textContent).toEqual('31.7 K');
expect(cells[3].textContent).toEqual('31.7 Bil');
expect(cells[25].textContent).toEqual('net/http.(*conn).serve');
expect(cells[26].textContent).toEqual('5.63 K');
expect(cells[27].textContent).toEqual('5.63 Bil');
});
it('should render search and sandwich buttons', async () => {
// Needed for AutoSizer to work in test
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 500 });
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 500 });
const { mocks } = setup();
const searchButtons = screen.getAllByLabelText(/Search for symbol/);
expect(searchButtons.length > 0).toBeTruthy();
await userEvents.click(searchButtons[0]);
expect(mocks.onSearch).toHaveBeenCalledWith('net/http.HandlerFunc.ServeHTTP');
const sandwichButtons = screen.getAllByLabelText(/Show in sandwich view/);
expect(sandwichButtons.length > 0).toBeTruthy();
await userEvents.click(sandwichButtons[0]);
expect(mocks.onSandwich).toHaveBeenCalledWith('net/http.HandlerFunc.ServeHTTP');
});
});
@@ -4,7 +4,15 @@ import AutoSizer from 'react-virtualized-auto-sizer';
import { applyFieldOverrides, CoreApp, DataFrame, DataLinkClickEvent, Field, FieldType } from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { Table, TableSortByFieldState, useStyles2 } from '@grafana/ui';
import {
IconButton,
Table,
TableCellDisplayMode,
TableCustomCellOptions,
TableFieldOptions,
TableSortByFieldState,
useStyles2,
} from '@grafana/ui';
import { TOP_TABLE_COLUMN_WIDTH } from '../../constants';
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
@@ -15,50 +23,62 @@ type Props = {
app: CoreApp;
onSymbolClick: (symbol: string) => void;
height?: number;
search?: string;
sandwichItem?: string;
onSearch: (str: string) => void;
onSandwich: (str?: string) => void;
};
const FlameGraphTopTableContainer = ({ data, app, onSymbolClick, height }: Props) => {
const styles = useStyles2(getStyles);
const FlameGraphTopTableContainer = React.memo(
({ data, app, onSymbolClick, height, search, onSearch, sandwichItem, onSandwich }: Props) => {
const styles = useStyles2(getStyles);
const [sort, setSort] = useState<TableSortByFieldState[]>([{ displayName: 'Self', desc: true }]);
const [sort, setSort] = useState<TableSortByFieldState[]>([{ displayName: 'Self', desc: true }]);
return (
<div className={styles.topTableContainer} data-testid="topTable">
<AutoSizer style={{ width: '100%', height }}>
{({ width, height }) => {
if (width < 3 || height < 3) {
return null;
}
return (
<div className={styles.topTableContainer} data-testid="topTable">
<AutoSizer style={{ width: '100%', height }}>
{({ width, height }) => {
if (width < 3 || height < 3) {
return null;
}
const frame = buildTableDataFrame(data, width, onSymbolClick);
return (
<Table
initialSortBy={sort}
onSortByChange={(s) => {
if (s && s.length) {
reportInteraction('grafana_flamegraph_table_sort_selected', {
app,
grafana_version: config.buildInfo.version,
sort: s[0].displayName + '_' + (s[0].desc ? 'desc' : 'asc'),
});
}
setSort(s);
}}
data={frame}
width={width}
height={height}
/>
);
}}
</AutoSizer>
</div>
);
};
const frame = buildTableDataFrame(data, width, onSymbolClick, onSearch, onSandwich, search, sandwichItem);
return (
<Table
initialSortBy={sort}
onSortByChange={(s) => {
if (s && s.length) {
reportInteraction('grafana_flamegraph_table_sort_selected', {
app,
grafana_version: config.buildInfo.version,
sort: s[0].displayName + '_' + (s[0].desc ? 'desc' : 'asc'),
});
}
setSort(s);
}}
data={frame}
width={width}
height={height}
/>
);
}}
</AutoSizer>
</div>
);
}
);
FlameGraphTopTableContainer.displayName = 'FlameGraphTopTableContainer';
function buildTableDataFrame(
data: FlameGraphDataContainer,
width: number,
onSymbolClick: (str: string) => void
onSymbolClick: (str: string) => void,
onSearch: (str: string) => void,
onSandwich: (str?: string) => void,
search?: string,
sandwichItem?: string
): DataFrame {
// Group the data by label
// TODO: should be by filename + funcName + linenumber?
@@ -72,12 +92,14 @@ function buildTableDataFrame(
table[label].total = table[label].total ? table[label].total + value : value;
}
const actionField: Field = createActionField(onSandwich, onSearch, search, sandwichItem);
const symbolField: Field = {
type: FieldType.string,
name: 'Symbol',
values: [],
config: {
custom: { width: width - TOP_TABLE_COLUMN_WIDTH * 2 },
custom: { width: width - actionColumnWidth - TOP_TABLE_COLUMN_WIDTH * 2 },
links: [
{
title: 'Highlight symbol',
@@ -92,27 +114,17 @@ function buildTableDataFrame(
},
};
const selfField: Field = {
type: FieldType.number,
name: 'Self',
values: [],
config: { unit: data.selfField.config.unit, custom: { width: TOP_TABLE_COLUMN_WIDTH } },
};
const totalField: Field = {
type: FieldType.number,
name: 'Total',
values: [],
config: { unit: data.valueField.config.unit, custom: { width: TOP_TABLE_COLUMN_WIDTH } },
};
const selfField = createNumberField('Self', data.selfField.config.unit);
const totalField = createNumberField('Total', data.valueField.config.unit);
for (let key in table) {
actionField.values.push(null);
symbolField.values.push(key);
selfField.values.push(table[key].self);
totalField.values.push(table[key].total);
}
const frame = { fields: [symbolField, selfField, totalField], length: symbolField.values.length };
const frame = { fields: [actionField, symbolField, selfField, totalField], length: symbolField.values.length };
const dataFrames = applyFieldOverrides({
data: [frame],
@@ -127,13 +139,119 @@ function buildTableDataFrame(
return dataFrames[0];
}
function createNumberField(name: string, unit?: string): Field {
return {
type: FieldType.number,
name,
values: [],
config: { unit, custom: { width: TOP_TABLE_COLUMN_WIDTH } },
};
}
const actionColumnWidth = 61;
function createActionField(
onSandwich: (str?: string) => void,
onSearch: (str: string) => void,
search?: string,
sandwichItem?: string
): Field {
const options: TableCustomCellOptions = {
type: TableCellDisplayMode.Custom,
cellComponent: (props) => {
return (
<ActionCell
frame={props.frame}
onSandwich={onSandwich}
onSearch={onSearch}
search={search}
sandwichItem={sandwichItem}
rowIndex={props.rowIndex}
/>
);
},
};
const actionFieldTableConfig: TableFieldOptions = {
filterable: false,
width: actionColumnWidth,
hideHeader: true,
inspect: false,
align: 'auto',
cellOptions: options,
};
return {
type: FieldType.number,
name: 'actions',
values: [],
config: {
custom: actionFieldTableConfig,
},
};
}
type ActionCellProps = {
frame: DataFrame;
rowIndex: number;
search?: string;
sandwichItem?: string;
onSearch: (symbol: string) => void;
onSandwich: (symbol: string) => void;
};
function ActionCell(props: ActionCellProps) {
const styles = useStyles2(getStyles);
const symbol = props.frame.fields.find((f: Field) => f.name === 'Symbol')?.values.get(props.rowIndex);
const isSearched = props.search === symbol;
const isSandwiched = props.sandwichItem === symbol;
return (
<div className={styles.actionCellWrapper}>
<IconButton
className={styles.actionCellButton}
name={'search'}
variant={isSearched ? 'primary' : 'secondary'}
tooltip={isSearched ? 'Clear from search' : 'Search for symbol'}
aria-label={isSearched ? 'Clear from search' : 'Search for symbol'}
onClick={() => {
props.onSearch(isSearched ? '' : symbol);
}}
/>
<IconButton
className={styles.actionCellButton}
name={'gf-show-context'}
tooltip={isSandwiched ? 'Remove from sandwich view' : 'Show in sandwich view'}
variant={isSandwiched ? 'primary' : 'secondary'}
aria-label={isSandwiched ? 'Remove from sandwich view' : 'Show in sandwich view'}
onClick={() => {
props.onSandwich(isSandwiched ? undefined : symbol);
}}
/>
</div>
);
}
const getStyles = () => {
return {
topTableContainer: css`
label: topTableContainer;
flex-grow: 1;
flex-basis: 50%;
overflow: hidden;
`,
actionCellWrapper: css`
label: actionCellWrapper;
display: flex;
height: 24px;
`,
actionCellButton: css`
label: actionCellButton;
margin-right: 0;
width: 24px;
`,
};
};