From c2778325f68b51e5986d2a6911657791aae7484b Mon Sep 17 00:00:00 2001 From: Andrej Ocenas Date: Tue, 18 Jul 2023 09:54:25 +0200 Subject: [PATCH] FlameGraph: Add column in table with buttons to filter and sandwich a symbol (#71773) --- .../components/FlameGraphContainer.tsx | 40 ++-- .../FlameGraphTopTableContainer.test.tsx | 65 ++++-- .../TopTable/FlameGraphTopTableContainer.tsx | 220 ++++++++++++++---- 3 files changed, 241 insertions(+), 84 deletions(-) diff --git a/public/app/plugins/panel/flamegraph/components/FlameGraphContainer.tsx b/public/app/plugins/panel/flamegraph/components/FlameGraphContainer.tsx index d87b8157b8c..3f8ef5b6129 100644 --- a/public/app/plugins/panel/flamegraph/components/FlameGraphContainer.tsx +++ b/public/app/plugins/panel/flamegraph/components/FlameGraphContainer.tsx @@ -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) => { { - 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} /> )} diff --git a/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.test.tsx b/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.test.tsx index 6364f74022a..0b6e030f4e9 100644 --- a/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.test.tsx +++ b/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.test.tsx @@ -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 ; + const renderResult = render( + + ); + + return { renderResult, mocks: { onSearch, onSandwich } }; }; - it('should render without error', async () => { - expect(() => render()).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(); + 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'); }); }); diff --git a/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.tsx b/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.tsx index 6b136f7f66e..6b157e7125e 100644 --- a/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.tsx +++ b/public/app/plugins/panel/flamegraph/components/TopTable/FlameGraphTopTableContainer.tsx @@ -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([{ displayName: 'Self', desc: true }]); + const [sort, setSort] = useState([{ displayName: 'Self', desc: true }]); - return ( -
- - {({ width, height }) => { - if (width < 3 || height < 3) { - return null; - } + return ( +
+ + {({ width, height }) => { + if (width < 3 || height < 3) { + return null; + } - const frame = buildTableDataFrame(data, width, onSymbolClick); - return ( - { - 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} - /> - ); - }} - - - ); -}; + const frame = buildTableDataFrame(data, width, onSymbolClick, onSearch, onSandwich, search, sandwichItem); + return ( +
{ + 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} + /> + ); + }} + + + ); + } +); + +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 ( + + ); + }, + }; + + 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 ( +
+ { + props.onSearch(isSearched ? '' : symbol); + }} + /> + { + props.onSandwich(isSandwiched ? undefined : symbol); + }} + /> +
+ ); +} + 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; + `, }; };