FlameGraph: Add column in table with buttons to filter and sandwich a symbol (#71773)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
+47
-18
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+169
-51
@@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user