mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
FrameGraphTopTable: Use standard Table component (#66411)
* FrameGraphTopTable: Use standard Table component * Simplify * Fix test * Update tests * Fixing test
This commit is contained in:
@@ -39,11 +39,11 @@ describe('FlameGraphContainer', () => {
|
||||
|
||||
it('should update search when row selected in top table', async () => {
|
||||
render(<FlameGraphContainerWithProps />);
|
||||
await userEvent.click((await screen.findAllByRole('row'))[1]);
|
||||
await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[0]);
|
||||
expect(screen.getByDisplayValue('net/http.HandlerFunc.ServeHTTP')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getAllByRole('row')[2]);
|
||||
await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[1]);
|
||||
expect(screen.getByDisplayValue('total')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getAllByRole('row')[2]);
|
||||
await userEvent.click((await screen.findAllByTitle('Highlight symbol'))[1]);
|
||||
expect(screen.queryByDisplayValue('total')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const FlameGraphHeader = ({
|
||||
{ value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' },
|
||||
{ value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' },
|
||||
];
|
||||
|
||||
if (containerWidth >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH) {
|
||||
viewOptions.push({
|
||||
value: SelectedView.Both,
|
||||
@@ -47,6 +48,14 @@ const FlameGraphHeader = ({
|
||||
});
|
||||
}
|
||||
|
||||
const onResetView = () => {
|
||||
setTopLevelIndex(0);
|
||||
setSelectedBarIndex(0);
|
||||
setRangeMin(0);
|
||||
setRangeMax(1);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.leftContainer}>
|
||||
@@ -57,22 +66,11 @@ const FlameGraphHeader = ({
|
||||
setSearch(v.currentTarget.value);
|
||||
}}
|
||||
placeholder={'Search..'}
|
||||
width={24}
|
||||
width={44}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type={'button'}
|
||||
variant={'secondary'}
|
||||
size={'md'}
|
||||
onClick={() => {
|
||||
setTopLevelIndex(0);
|
||||
setSelectedBarIndex(0);
|
||||
setRangeMin(0);
|
||||
setRangeMax(1);
|
||||
setSearch('');
|
||||
}}
|
||||
>
|
||||
Reset View
|
||||
<Button type={'button'} variant="secondary" onClick={onResetView}>
|
||||
Reset view
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { CSSProperties, useCallback, useMemo } from 'react';
|
||||
import { SortByFn, useSortBy, useAbsoluteLayout, useTable, CellProps } from 'react-table';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, useStyles2, CustomScrollbar } from '@grafana/ui';
|
||||
|
||||
import { TOP_TABLE_COLUMN_WIDTH } from '../../constants';
|
||||
import { ColumnTypes, TopTableData, TopTableValue } from '../types';
|
||||
|
||||
type Props = {
|
||||
width: number;
|
||||
height: number;
|
||||
data: TopTableData[];
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
setTopLevelIndex: (level: number) => void;
|
||||
setSelectedBarIndex: (bar: number) => void;
|
||||
setRangeMin: (range: number) => void;
|
||||
setRangeMax: (range: number) => void;
|
||||
};
|
||||
|
||||
const FlameGraphTopTable = ({
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
search,
|
||||
setSearch,
|
||||
setTopLevelIndex,
|
||||
setSelectedBarIndex,
|
||||
setRangeMin,
|
||||
setRangeMax,
|
||||
}: Props) => {
|
||||
const styles = useStyles2((theme) => getStyles(theme));
|
||||
|
||||
const sortSymbols: SortByFn<object> = (a, b, column) => {
|
||||
return a.values[column].localeCompare(b.values[column]);
|
||||
};
|
||||
|
||||
const sortUnits: SortByFn<object> = (a, b, column) => {
|
||||
return a.values[column].value.toString().localeCompare(b.values[column].value.toString(), 'en', { numeric: true });
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessor: ColumnTypes.Symbol.toLowerCase(),
|
||||
header: ColumnTypes.Symbol,
|
||||
cell: SymbolCell,
|
||||
sortType: sortSymbols,
|
||||
width: width - TOP_TABLE_COLUMN_WIDTH * 2,
|
||||
},
|
||||
{
|
||||
accessor: ColumnTypes.Self.toLowerCase(),
|
||||
header: ColumnTypes.Self,
|
||||
cell: UnitCell,
|
||||
sortType: sortUnits,
|
||||
width: TOP_TABLE_COLUMN_WIDTH,
|
||||
},
|
||||
{
|
||||
accessor: ColumnTypes.Total.toLowerCase(),
|
||||
header: ColumnTypes.Total,
|
||||
cell: UnitCell,
|
||||
sortType: sortUnits,
|
||||
width: TOP_TABLE_COLUMN_WIDTH,
|
||||
},
|
||||
],
|
||||
[width]
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
columns,
|
||||
data,
|
||||
initialState: {
|
||||
sortBy: [
|
||||
{
|
||||
id: ColumnTypes.Self.toLowerCase(),
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[columns, data]
|
||||
);
|
||||
|
||||
const rowClicked = useCallback(
|
||||
(symbol: string) => {
|
||||
if (search === symbol) {
|
||||
setSearch('');
|
||||
} else {
|
||||
setSearch(symbol);
|
||||
// Reset selected level in flamegraph when selecting row in top table
|
||||
setTopLevelIndex(0);
|
||||
setSelectedBarIndex(0);
|
||||
setRangeMin(0);
|
||||
setRangeMax(1);
|
||||
}
|
||||
},
|
||||
[search, setRangeMax, setRangeMin, setSearch, setTopLevelIndex, setSelectedBarIndex]
|
||||
);
|
||||
|
||||
const { headerGroups, rows, prepareRow } = useTable(options, useSortBy, useAbsoluteLayout);
|
||||
|
||||
const renderRow = React.useCallback(
|
||||
({ index, style }: { index: number; style: CSSProperties }) => {
|
||||
let row = rows[index];
|
||||
prepareRow(row);
|
||||
|
||||
const symbol = row.values[ColumnTypes.Symbol.toLowerCase()];
|
||||
const classNames = cx(symbol === search && styles.matchedRow, styles.row);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...row.getRowProps({ style })}
|
||||
className={classNames}
|
||||
onClick={() => {
|
||||
rowClicked(symbol);
|
||||
}}
|
||||
>
|
||||
{row.cells.map((cell) => {
|
||||
const { key, ...cellProps } = cell.getCellProps();
|
||||
if (cellProps.style) {
|
||||
cellProps.style.minWidth = cellProps.style.width;
|
||||
}
|
||||
return (
|
||||
<div key={key} className={styles.cell} {...cellProps}>
|
||||
{cell.render('cell')}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[rows, prepareRow, search, styles.matchedRow, styles.row, styles.cell, rowClicked]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.table(height)} data-testid="topTable">
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps();
|
||||
|
||||
return (
|
||||
<div key={key} className={styles.header} {...headerGroupProps}>
|
||||
{headerGroup.headers.map((column) => {
|
||||
const { key, ...headerProps } = column.getHeaderProps(
|
||||
column.canSort ? column.getSortByToggleProps() : undefined
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={key} className={styles.headerCell} {...headerProps}>
|
||||
{column.render('header')}
|
||||
{column.isSorted && <Icon name={column.isSortedDesc ? 'arrow-down' : 'arrow-up'} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{rows.length > 0 ? (
|
||||
<CustomScrollbar hideVerticalTrack={true}>
|
||||
<FixedSizeList
|
||||
height={height}
|
||||
itemCount={rows.length}
|
||||
itemSize={38}
|
||||
width={'100%'}
|
||||
style={{ overflow: 'hidden auto' }}
|
||||
>
|
||||
{renderRow}
|
||||
</FixedSizeList>
|
||||
</CustomScrollbar>
|
||||
) : (
|
||||
<div style={{ height: height }} className={styles.noData}>
|
||||
No data
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SymbolCell = ({ cell: { value } }: CellProps<TopTableValue, string>) => {
|
||||
return <div>{value}</div>;
|
||||
};
|
||||
|
||||
const UnitCell = ({ cell: { value } }: CellProps<TopTableValue, TopTableValue>) => {
|
||||
return <div>{value.unitValue}</div>;
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
table: (height: number) => {
|
||||
return css`
|
||||
background-color: ${theme.colors.background.primary};
|
||||
height: ${height}px;
|
||||
overflow: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
},
|
||||
header: css`
|
||||
height: 38px;
|
||||
|
||||
& > :nth-child(2),
|
||||
& > :nth-child(3) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// needed to keep header row height fixed so header row does not resize with browser
|
||||
& > :nth-child(3) {
|
||||
position: relative !important;
|
||||
}
|
||||
`,
|
||||
headerCell: css`
|
||||
background-color: ${theme.colors.background.secondary};
|
||||
color: ${theme.colors.primary.text};
|
||||
padding: ${theme.spacing(1)};
|
||||
`,
|
||||
matchedRow: css`
|
||||
& > :nth-child(1),
|
||||
& > :nth-child(2),
|
||||
& > :nth-child(3) {
|
||||
background-color: ${theme.colors.background.secondary} !important;
|
||||
}
|
||||
`,
|
||||
row: css`
|
||||
border-top: 1px solid ${theme.components.panel.borderColor};
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)};
|
||||
}
|
||||
& > :nth-child(2),
|
||||
& > :nth-child(3) {
|
||||
text-align: right;
|
||||
}
|
||||
& > :nth-child(3) {
|
||||
border-right: none;
|
||||
}
|
||||
`,
|
||||
cell: css`
|
||||
border-right: 1px solid ${theme.components.panel.borderColor};
|
||||
padding: ${theme.spacing(1)};
|
||||
|
||||
div {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
&:hover {
|
||||
overflow: visible;
|
||||
width: auto !important;
|
||||
box-shadow: 0 0 2px ${theme.colors.primary.main};
|
||||
background-color: ${theme.colors.emphasize(theme.colors.background.primary, 0.03)};
|
||||
z-index: 1;
|
||||
}
|
||||
`,
|
||||
noData: css`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`,
|
||||
});
|
||||
|
||||
export default FlameGraphTopTable;
|
||||
@@ -45,7 +45,7 @@ describe('FlameGraphTopTableContainer', () => {
|
||||
|
||||
render(<FlameGraphTopTableContainerWithProps />);
|
||||
const rows = screen.getAllByRole('row');
|
||||
expect(rows).toHaveLength(17); // + 1 for the columnHeaders
|
||||
expect(rows).toHaveLength(16);
|
||||
|
||||
const columnHeaders = screen.getAllByRole('columnheader');
|
||||
expect(columnHeaders).toHaveLength(3);
|
||||
@@ -54,7 +54,7 @@ describe('FlameGraphTopTableContainer', () => {
|
||||
expect(columnHeaders[2].textContent).toEqual('Total');
|
||||
|
||||
const cells = screen.getAllByRole('cell');
|
||||
expect(cells).toHaveLength(48); // 16 rows
|
||||
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');
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useMemo } from 'react';
|
||||
import React from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { CoreApp, DisplayValue } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import {
|
||||
applyFieldOverrides,
|
||||
ArrayVector,
|
||||
CoreApp,
|
||||
DataFrame,
|
||||
DataLinkClickEvent,
|
||||
Field,
|
||||
FieldType,
|
||||
} from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Table, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { PIXELS_PER_LEVEL } from '../../constants';
|
||||
import { PIXELS_PER_LEVEL, TOP_TABLE_COLUMN_WIDTH } from '../../constants';
|
||||
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
|
||||
import { SelectedView, TableData, TopTableData } from '../types';
|
||||
|
||||
import FlameGraphTopTable from './FlameGraphTopTable';
|
||||
import { SelectedView, TableData } from '../types';
|
||||
|
||||
type Props = {
|
||||
data: FlameGraphDataContainer;
|
||||
@@ -38,68 +45,107 @@ const FlameGraphTopTableContainer = ({
|
||||
}: Props) => {
|
||||
const styles = useStyles2(() => getStyles(selectedView, app));
|
||||
|
||||
const topTable = useMemo(() => {
|
||||
// Group the data by label
|
||||
// TODO: should be by filename + funcName + linenumber?
|
||||
let table: { [key: string]: TableData } = {};
|
||||
for (let i = 0; i < data.data.length; i++) {
|
||||
const value = data.getValue(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;
|
||||
const onSymbolClick = (symbol: string) => {
|
||||
if (search === symbol) {
|
||||
setSearch('');
|
||||
} else {
|
||||
setSearch(symbol);
|
||||
// Reset selected level in flamegraph when selecting row in top table
|
||||
setTopLevelIndex(0);
|
||||
setSelectedBarIndex(0);
|
||||
setRangeMin(0);
|
||||
setRangeMax(1);
|
||||
}
|
||||
};
|
||||
|
||||
let topTable: TopTableData[] = [];
|
||||
for (let key in table) {
|
||||
const selfUnit = handleUnits(data.valueDisplayProcessor(table[key].self), data.getUnitTitle());
|
||||
const valueUnit = handleUnits(data.valueDisplayProcessor(table[key].total), data.getUnitTitle());
|
||||
|
||||
topTable.push({
|
||||
symbol: key,
|
||||
self: { value: table[key].self, unitValue: selfUnit },
|
||||
total: { value: table[key].total, unitValue: valueUnit },
|
||||
});
|
||||
}
|
||||
|
||||
return topTable;
|
||||
}, [data]);
|
||||
const initialSortBy = [{ displayName: 'Self', desc: true }];
|
||||
|
||||
return (
|
||||
<>
|
||||
{topTable && (
|
||||
<div className={styles.topTableContainer}>
|
||||
<AutoSizer style={{ width: '100%', height: PIXELS_PER_LEVEL * totalLevels + 'px' }}>
|
||||
{({ width, height }) => (
|
||||
<FlameGraphTopTable
|
||||
width={width}
|
||||
height={height}
|
||||
data={topTable}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
setTopLevelIndex={setTopLevelIndex}
|
||||
setSelectedBarIndex={setSelectedBarIndex}
|
||||
setRangeMin={setRangeMin}
|
||||
setRangeMax={setRangeMax}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<div className={styles.topTableContainer} data-testid="topTable">
|
||||
<AutoSizer style={{ width: '100%', height: PIXELS_PER_LEVEL * totalLevels + 'px' }}>
|
||||
{({ width, height }) => {
|
||||
if (width < 3 || height < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frame = buildTableDataFrame(data, width, onSymbolClick);
|
||||
return <Table initialSortBy={initialSortBy} data={frame} width={width} height={height} />;
|
||||
}}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function handleUnits(displayValue: DisplayValue, unit: string) {
|
||||
let unitValue = displayValue.text + displayValue.suffix;
|
||||
if (unit === 'Count') {
|
||||
if (!displayValue.suffix) {
|
||||
// Makes sure we don't show 123undefined or something like that if suffix isn't defined
|
||||
unitValue = displayValue.text;
|
||||
}
|
||||
function buildTableDataFrame(
|
||||
data: FlameGraphDataContainer,
|
||||
width: number,
|
||||
onSymbolClick: (str: string) => void
|
||||
): DataFrame {
|
||||
// Group the data by label
|
||||
// TODO: should be by filename + funcName + linenumber?
|
||||
let table: { [key: string]: TableData } = {};
|
||||
for (let i = 0; i < data.data.length; i++) {
|
||||
const value = data.getValue(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;
|
||||
}
|
||||
return unitValue;
|
||||
|
||||
const symbolField = {
|
||||
type: FieldType.string,
|
||||
name: 'Symbol',
|
||||
values: new ArrayVector(),
|
||||
config: {
|
||||
custom: { width: width - TOP_TABLE_COLUMN_WIDTH * 2 },
|
||||
links: [
|
||||
{
|
||||
title: 'Highlight symbol',
|
||||
url: '',
|
||||
onClick: (e: DataLinkClickEvent) => {
|
||||
const field: Field = e.origin.field;
|
||||
const value = field.values.get(e.origin.rowIndex);
|
||||
onSymbolClick(value);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const selfField = {
|
||||
type: FieldType.number,
|
||||
name: 'Self',
|
||||
values: new ArrayVector(),
|
||||
config: { unit: data.selfField.config.unit, custom: { width: TOP_TABLE_COLUMN_WIDTH } },
|
||||
};
|
||||
|
||||
const totalField = {
|
||||
type: FieldType.number,
|
||||
name: 'Total',
|
||||
values: new ArrayVector(),
|
||||
config: { unit: data.valueField.config.unit, custom: { width: TOP_TABLE_COLUMN_WIDTH } },
|
||||
};
|
||||
|
||||
for (let key in table) {
|
||||
symbolField.values.add(key);
|
||||
selfField.values.add(table[key].self);
|
||||
totalField.values.add(table[key].total);
|
||||
}
|
||||
|
||||
const frame = { fields: [symbolField, selfField, totalField], length: symbolField.values.length };
|
||||
|
||||
const dataFrames = applyFieldOverrides({
|
||||
data: [frame],
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
replaceVariables: (value: string) => value,
|
||||
theme: config.theme2,
|
||||
});
|
||||
|
||||
return dataFrames[0];
|
||||
}
|
||||
|
||||
const getStyles = (selectedView: SelectedView, app: CoreApp) => {
|
||||
|
||||
Reference in New Issue
Block a user