Table: Support display of multiple sub tables (#71953)

* Add nested option to DataFrame. Refactor Table to use nested dataframes for sub-tables

* Use nested frames for TraceQL response

* debugging

* Fix cell text and table position

* Update getItemSize

* noHeader size

* Update sub table renderer

* Update table container height

* Cleanup and fix RawPrometheusContainer height

* Update resultTransformer and docker script

* Updates to TableContainer, resultTransformer after merge

* Fixes for table pagination in dashboards

* Cell height and show footer enhancement/fix

* Sub table links

* Update RawPrometheusContainer

* Remove console log

* Update tests

* Update storybook

* Remove Tempo demo

* Store nested data in single field via its values

* Move nested prop into custom

* Tempo demo

* Add field type & update incorrect logic

* Update docker compose image for Tempo

* Update packages/grafana-data/src/field/fieldOverrides.ts

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>

* Simplify logic for getting nestedFrames and rendering sub tables

* Update docs for table

* Update nested table bg color

* Lighten nested table bg color

* Renames

* Migrate frames using parentRowIndex and add deprecation notice

* Update title

* Align expander icon size between Table and interactive table

* Table: Refactor out the expanded rows bits

* fix spacing

* Add line along left side for expanded rows

* Disable hover row background when expanded

---------

Co-authored-by: André Pereira <adrapereira@gmail.com>
Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Joey
2023-08-10 12:33:46 +01:00
committed by GitHub
co-authored by Piotr Jamróz André Pereira Torkel Ödegaard
parent 43aab615c3
commit 8c2f439cd7
24 changed files with 711 additions and 313 deletions
+4
View File
@@ -1182,6 +1182,10 @@ exports[`better eslint`] = {
"packages/grafana-ui/src/components/Table/DefaultCell.tsx:5381": [ "packages/grafana-ui/src/components/Table/DefaultCell.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
"packages/grafana-ui/src/components/Table/ExpandedRow.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"packages/grafana-ui/src/components/Table/Filter.tsx:5381": [ "packages/grafana-ui/src/components/Table/Filter.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],
@@ -72,7 +72,7 @@
labels: namespace labels: namespace
tempo: tempo:
image: grafana/tempo:main-dcf8a2a image: grafana/tempo:latest
command: command:
- --config.file=/etc/tempo.yaml - --config.file=/etc/tempo.yaml
volumes: volumes:
@@ -204,6 +204,51 @@ describe('applyFieldOverrides', () => {
}); });
}); });
describe('given nested data frames', () => {
const f0Nested = createDataFrame({
name: 'nested',
fields: [{ name: 'info', type: FieldType.string, values: [10, 20] }],
});
const f0 = createDataFrame({
name: 'A',
fields: [
{
name: 'message',
type: FieldType.string,
values: [10, 20],
},
{
name: 'nested',
type: FieldType.nestedFrames,
values: [[f0Nested]],
},
],
});
it('should add scopedVars to fields', () => {
const withOverrides = applyFieldOverrides({
data: [f0],
fieldConfig: {
defaults: {},
overrides: [],
},
replaceVariables: (value) => value,
theme: createTheme(),
fieldConfigRegistry: new FieldConfigOptionsRegistry(),
});
expect(withOverrides[0].fields[1].values[0][0].fields[0].state!.scopedVars?.__dataContext?.value.frame).toBe(
withOverrides[0].fields[1].values[0][0]
);
expect(withOverrides[0].fields[1].values[0][0].fields[0].state!.scopedVars?.__dataContext?.value.frameIndex).toBe(
0
);
expect(withOverrides[0].fields[1].values[0][0].fields[0].state!.scopedVars?.__dataContext?.value.field).toBe(
withOverrides[0].fields[1].values[0][0].fields[0]
);
});
});
it('will merge FieldConfig with default values', () => { it('will merge FieldConfig with default values', () => {
const field: FieldConfig = { const field: FieldConfig = {
min: 0, min: 0,
@@ -207,6 +207,36 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
options.timeZone, options.timeZone,
options.dataLinkPostProcessor options.dataLinkPostProcessor
); );
if (field.type === FieldType.nestedFrames) {
for (const nestedFrames of field.values) {
for (let nfIndex = 0; nfIndex < nestedFrames.length; nfIndex++) {
for (const valueField of nestedFrames[nfIndex].fields) {
valueField.state = {
scopedVars: {
__dataContext: {
value: {
data: nestedFrames,
frame: nestedFrames[nfIndex],
frameIndex: nfIndex,
field: valueField,
},
},
},
};
valueField.getLinks = getLinksSupplier(
nestedFrames[nfIndex],
valueField,
valueField.state!.scopedVars,
context.replaceVariables,
options.timeZone,
options.dataLinkPostProcessor
);
}
}
}
}
} }
return newFrame; return newFrame;
@@ -19,6 +19,7 @@ export enum FieldType {
enum = 'enum', enum = 'enum',
other = 'other', // Object, Array, etc other = 'other', // Object, Array, etc
frame = 'frame', // DataFrame frame = 'frame', // DataFrame
nestedFrames = 'nestedFrames', // @alpha Nested DataFrames
} }
/** /**
@@ -22,6 +22,7 @@ export function ExpanderCell<K extends object>({ row, __rowID }: CellProps<K, vo
aria-expanded={row.isExpanded} aria-expanded={row.isExpanded}
// @ts-expect-error same as the line above // @ts-expect-error same as the line above
{...row.getToggleRowExpandedProps()} {...row.getToggleRowExpandedProps()}
size="lg"
/> />
</div> </div>
); );
@@ -0,0 +1,86 @@
import { css } from '@emotion/css';
import React, { CSSProperties } from 'react';
import { DataFrame, Field, GrafanaTheme2 } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
import { useStyles2, useTheme2 } from '../../themes';
import { Table } from './Table';
import { TableStyles } from './styles';
import { EXPANDER_WIDTH } from './utils';
export interface Props {
nestedData: Field;
tableStyles: TableStyles;
rowIndex: number;
width: number;
cellHeight: TableCellHeight;
}
export function ExpandedRow({ tableStyles, nestedData, rowIndex, width, cellHeight }: Props) {
const frames = nestedData.values as DataFrame[][];
const subTables: React.ReactNode[] = [];
const theme = useTheme2();
const styles = useStyles2(getStyles);
let top = tableStyles.rowHeight + theme.spacing.gridSize; // initial height for row that expands above sub tables + 1 grid unit spacing
frames[rowIndex].forEach((nf: DataFrame, nfIndex: number) => {
const noHeader = !!nf.meta?.custom?.noHeader;
const height = tableStyles.rowHeight * (nf.length + (noHeader ? 0 : 1)); // account for the header with + 1
const subTable: CSSProperties = {
height: height,
paddingLeft: EXPANDER_WIDTH,
position: 'absolute',
top,
};
top += height + theme.spacing.gridSize;
subTables.push(
<div style={subTable} key={`subTable_${rowIndex}_${nfIndex}`}>
<Table
data={nf}
width={width - EXPANDER_WIDTH}
height={tableStyles.rowHeight * (nf.length + 1)}
noHeader={noHeader}
cellHeight={cellHeight}
/>
</div>
);
});
return <div className={styles.subTables}>{subTables}</div>;
}
const getStyles = (theme: GrafanaTheme2) => {
return {
subTables: css({
'&:before': {
content: '""',
position: 'absolute',
width: '1px',
top: theme.spacing(5),
left: theme.spacing(1),
bottom: theme.spacing(2),
background: theme.colors.border.medium,
},
}),
};
};
export function getExpandedRowHeight(nestedData: Field, rowIndex: number, tableStyles: TableStyles) {
const frames = nestedData.values as DataFrame[][];
const height = frames[rowIndex].reduce((acc: number, frame: DataFrame) => {
if (frame.length) {
const noHeader = !!frame.meta?.custom?.noHeader;
return acc + tableStyles.rowHeight * (frame.length + (noHeader ? 0 : 1)) + 8; // account for the header with + 1
}
return acc;
}, tableStyles.rowHeight); // initial height for row that expands above sub tables
return height ?? tableStyles.rowHeight;
}
@@ -16,7 +16,7 @@ export function RowExpander({ row, tableStyles }: Props) {
<Icon <Icon
aria-label={row.isExpanded ? 'Collapse row' : 'Expand row'} aria-label={row.isExpanded ? 'Collapse row' : 'Expand row'}
name={row.isExpanded ? 'angle-down' : 'angle-right'} name={row.isExpanded ? 'angle-down' : 'angle-right'}
size="xl" size="lg"
/> />
</div> </div>
); );
@@ -7,11 +7,18 @@ Used for displaying tabular data
## Sub-tables ## Sub-tables
Sub-tables are supported through the usage of the prop `subData` Dataframe array. Sub-tables are supported by adding `FieldType.nestedFrames` to the field that contains the nested data in your dataframe.
The frames are linked to each row using the following custom properties under `dataframe.meta.custom`
- **parentRowIndex**: number - The index of the parent row in the main dataframe (under the `data` prop of the Table component) This nested fields values can contain an array of one or more dataframes. Each of these dataframes will be rendered as one unique sub-table.
- **noHeader**: boolean - Sets the noHeader of each sub-table
For each dataframe and index in the nested field, the dataframe will be rendered as one or more sub-tables below the main dataframe row at that index.
Each dataframe also supports using the following custom property under `dataframe.meta.custom`:
- **noHeader**: boolean - Hides that sub-tables header.
- @deprecated use `FieldType.nestedFrames` instead
- **parentRowIndex**: number - The index of the parent row in the main dataframe (under the `data` prop of the Table component).
## Cell rendering ## Cell rendering
@@ -42,7 +42,7 @@ const meta: Meta<typeof Table> = {
}, },
}; };
function buildData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): DataFrame { function buildData(theme: GrafanaTheme2, config: Record<string, FieldConfig>, rows = 1000): DataFrame {
const data = new MutableDataFrame({ const data = new MutableDataFrame({
fields: [ fields: [
{ name: 'Time', type: FieldType.time, values: [] }, // The time field { name: 'Time', type: FieldType.time, values: [] }, // The time field
@@ -87,7 +87,7 @@ function buildData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): D
field.config = merge(field.config, config[field.name]); field.config = merge(field.config, config[field.name]);
} }
for (let i = 0; i < 1000; i++) { for (let i = 0; i < rows; i++) {
data.appendRow([ data.appendRow([
new Date().getTime(), new Date().getTime(),
Math.random() * 2, Math.random() * 2,
@@ -100,59 +100,72 @@ function buildData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): D
return prepDataForStorybook([data], theme)[0]; return prepDataForStorybook([data], theme)[0];
} }
function buildSubTablesData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): DataFrame[] { function buildSubTablesData(theme: GrafanaTheme2, config: Record<string, FieldConfig>, rows: number): DataFrame {
const frames: DataFrame[] = []; const data = buildData(theme, {}, rows);
const allNestedFrames: DataFrame[][] = [];
for (let i = 0; i < 1000; i++) { for (let i = 0; i < rows; i++) {
const data = new MutableDataFrame({ const nestedFrames: DataFrame[] = [];
meta: {
custom: { for (let i = 0; i < Math.random() * 3; i++) {
parentRowIndex: i, const nestedData = new MutableDataFrame({
}, fields: [
}, { name: 'Time', type: FieldType.time, values: [] }, // The time field
fields: [ {
{ name: 'Time', type: FieldType.time, values: [] }, // The time field name: 'Quantity',
{ type: FieldType.number,
name: 'Quantity', values: [],
type: FieldType.number, config: {
values: [], decimals: 0,
config: { custom: {
decimals: 0, align: 'center',
custom: { },
align: 'center',
}, },
}, },
}, { name: 'Quality', type: FieldType.string, values: [] }, // The time field
{ name: 'Quality', type: FieldType.string, values: [] }, // The time field {
{ name: 'Progress',
name: 'Progress', type: FieldType.number,
type: FieldType.number, values: [],
values: [], config: {
config: { unit: 'percent',
unit: 'percent', min: 0,
min: 0, max: 100,
max: 100, },
}, },
}, ],
], });
});
for (const field of data.fields) { for (const field of nestedData.fields) {
field.config = merge(field.config, config[field.name]); field.config = merge(field.config, config[field.name]);
}
for (let i = 0; i < Math.random() * 4; i++) {
nestedData.appendRow([
new Date().getTime(),
Math.random() * 2,
Math.random() > 0.7 ? 'Good' : 'Bad',
Math.random() * 100,
]);
}
nestedFrames.push(nestedData);
} }
for (let i = 0; i < Math.random() * 4 + 1; i++) { allNestedFrames.push(prepDataForStorybook(nestedFrames, theme));
data.appendRow([
new Date().getTime(),
Math.random() * 2,
Math.random() > 0.7 ? 'Good' : 'Bad',
Math.random() * 100,
]);
}
frames.push(data);
} }
return prepDataForStorybook(frames, theme);
data.fields = [
...data.fields,
{
name: 'nested',
type: FieldType.nestedFrames,
values: allNestedFrames,
config: {},
},
];
return data;
} }
function buildFooterData(data: DataFrame): FooterItem[] { function buildFooterData(data: DataFrame): FooterItem[] {
@@ -257,19 +270,11 @@ Pagination.args = {
export const SubTables: StoryFn<typeof Table> = (args) => { export const SubTables: StoryFn<typeof Table> = (args) => {
const theme = useTheme2(); const theme = useTheme2();
const data = buildData(theme, {}); const data = buildSubTablesData(theme, {}, 100);
const subData = buildSubTablesData(theme, {
Progress: {
custom: {
displayMode: 'gradient-gauge',
},
thresholds: defaultThresholds,
},
});
return ( return (
<DashboardStoryCanvas> <DashboardStoryCanvas>
<Table {...args} data={data} subData={subData} /> <Table {...args} data={data} />
</DashboardStoryCanvas> </DashboardStoryCanvas>
); );
}; };
@@ -58,6 +58,10 @@ function getDefaultDataFrame(): DataFrame {
}, },
], ],
}); });
return applyOverrides(dataFrame);
}
function applyOverrides(dataFrame: DataFrame) {
const dataFrames = applyFieldOverrides({ const dataFrames = applyFieldOverrides({
data: [dataFrame], data: [dataFrame],
fieldConfig: { fieldConfig: {
@@ -515,31 +519,41 @@ describe('Table', () => {
}); });
}); });
describe('when mounted with data and sub-data', () => { describe('when mounted with nested data', () => {
it('then correct rows should be rendered and new table is rendered when expander is clicked', async () => { it('then correct rows should be rendered and new table is rendered when expander is clicked', async () => {
getTestContext({ const nestedFrame = (idx: number) =>
subData: new Array(getDefaultDataFrame().length).fill(0).map((i) => applyOverrides(
toDataFrame({ toDataFrame({
name: 'A', name: `nested_frame${idx}`,
fields: [ fields: [
{ {
name: 'number' + i, name: `humidity_${idx}`,
type: FieldType.number, type: FieldType.string,
values: [i, i, i], values: [`3%_${idx}`, `17%_${idx}`],
config: { },
custom: { {
filterable: true, name: `status_${idx}`,
}, type: FieldType.string,
}, values: [`ok_${idx}`, `humid_${idx}`],
}, },
], ],
meta: {
custom: {
parentRowIndex: i,
},
},
}) })
), );
const defaultFrame = getDefaultDataFrame();
getTestContext({
data: applyOverrides({
...defaultFrame,
fields: [
...defaultFrame.fields,
{
name: 'nested',
type: FieldType.nestedFrames,
values: [[nestedFrame(0), nestedFrame(1)]],
config: {},
},
],
}),
}); });
expect(getTable()).toBeInTheDocument(); expect(getTable()).toBeInTheDocument();
expect(screen.getAllByRole('columnheader')).toHaveLength(4); expect(screen.getAllByRole('columnheader')).toHaveLength(4);
@@ -557,11 +571,27 @@ describe('Table', () => {
]); ]);
await userEvent.click(within(rows[1]).getByLabelText('Expand row')); await userEvent.click(within(rows[1]).getByLabelText('Expand row'));
const rowsAfterClick = within(getTable()).getAllByRole('row'); expect(screen.getAllByRole('columnheader')).toHaveLength(8);
expect(within(rowsAfterClick[1]).getByRole('table')).toBeInTheDocument(); expect(getColumnHeader(/humidity_0/)).toBeInTheDocument();
expect(within(rowsAfterClick[1]).getByText(/number0/)).toBeInTheDocument(); expect(getColumnHeader(/humidity_1/)).toBeInTheDocument();
expect(getColumnHeader(/status_0/)).toBeInTheDocument();
expect(getColumnHeader(/status_1/)).toBeInTheDocument();
expect(within(rowsAfterClick[2]).queryByRole('table')).toBeNull(); const subTable0 = screen.getAllByRole('table')[1];
const subTableRows0 = within(subTable0).getAllByRole('row');
expect(subTableRows0).toHaveLength(3);
expect(within(subTableRows0[1]).getByText(/3%_0/)).toBeInTheDocument();
expect(within(subTableRows0[1]).getByText(/ok_0/)).toBeInTheDocument();
expect(within(subTableRows0[2]).getByText(/17%_0/)).toBeInTheDocument();
expect(within(subTableRows0[2]).getByText(/humid_0/)).toBeInTheDocument();
const subTable1 = screen.getAllByRole('table')[2];
const subTableRows1 = within(subTable1).getAllByRole('row');
expect(subTableRows1).toHaveLength(3);
expect(within(subTableRows1[1]).getByText(/3%_1/)).toBeInTheDocument();
expect(within(subTableRows1[1]).getByText(/ok_1/)).toBeInTheDocument();
expect(within(subTableRows1[2]).getByText(/17%_1/)).toBeInTheDocument();
expect(within(subTableRows1[2]).getByText(/humid_1/)).toBeInTheDocument();
}); });
}); });
}); });
@@ -1,3 +1,4 @@
import { css, cx } from '@emotion/css';
import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, UIEventHandler } from 'react'; import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, UIEventHandler } from 'react';
import { import {
Cell, Cell,
@@ -11,13 +12,14 @@ import {
} from 'react-table'; } from 'react-table';
import { VariableSizeList } from 'react-window'; import { VariableSizeList } from 'react-window';
import { Field, ReducerID } from '@grafana/data'; import { Field, FieldType, ReducerID } from '@grafana/data';
import { TableCellHeight } from '@grafana/schema'; import { TableCellHeight } from '@grafana/schema';
import { useTheme2 } from '../../themes'; import { useTheme2 } from '../../themes';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { Pagination } from '../Pagination/Pagination'; import { Pagination } from '../Pagination/Pagination';
import { getExpandedRowHeight, ExpandedRow } from './ExpandedRow';
import { FooterRow } from './FooterRow'; import { FooterRow } from './FooterRow';
import { HeaderRow } from './HeaderRow'; import { HeaderRow } from './HeaderRow';
import { TableCell } from './TableCell'; import { TableCell } from './TableCell';
@@ -25,14 +27,7 @@ import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks
import { getInitialState, useTableStateReducer } from './reducer'; import { getInitialState, useTableStateReducer } from './reducer';
import { useTableStyles } from './styles'; import { useTableStyles } from './styles';
import { FooterItem, GrafanaTableState, Props } from './types'; import { FooterItem, GrafanaTableState, Props } from './types';
import { import { getColumns, sortCaseInsensitive, sortNumber, getFooterItems, createFooterCalculationValues } from './utils';
getColumns,
sortCaseInsensitive,
sortNumber,
getFooterItems,
createFooterCalculationValues,
EXPANDER_WIDTH,
} from './utils';
const COLUMN_MIN_WIDTH = 150; const COLUMN_MIN_WIDTH = 150;
const FOOTER_ROW_HEIGHT = 36; const FOOTER_ROW_HEIGHT = 36;
@@ -41,7 +36,6 @@ export const Table = memo((props: Props) => {
const { const {
ariaLabel, ariaLabel,
data, data,
subData,
height, height,
onCellFilterAdded, onCellFilterAdded,
width, width,
@@ -106,10 +100,13 @@ export const Table = memo((props: Props) => {
footerOptions.reducer[0] === ReducerID.count footerOptions.reducer[0] === ReducerID.count
); );
const nestedDataField = data.fields.find((f) => f.type === FieldType.nestedFrames);
const hasNestedData = nestedDataField !== undefined;
// React-table column definitions // React-table column definitions
const memoizedColumns = useMemo( const memoizedColumns = useMemo(
() => getColumns(data, width, columnMinWidth, !!subData?.length, footerItems, isCountRowsSet), () => getColumns(data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet),
[data, width, columnMinWidth, footerItems, subData, isCountRowsSet] [data, width, columnMinWidth, footerItems, hasNestedData, isCountRowsSet]
); );
// Internal react table state reducer // Internal react table state reducer
@@ -207,35 +204,11 @@ export const Table = memo((props: Props) => {
useResetVariableListSizeCache(extendedState, listRef, data); useResetVariableListSizeCache(extendedState, listRef, data);
useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef); useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef);
const renderSubTable = useCallback( const rowIndexForPagination = useCallback(
(rowIndex: number) => { (index: number) => {
if (state.expanded[rowIndex]) { return state.pageIndex * state.pageSize + index;
const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === rowIndex);
if (rowSubData) {
const noHeader = !!rowSubData.meta?.custom?.noHeader;
const subTableStyle: CSSProperties = {
height: tableStyles.rowHeight * (rowSubData.length + (noHeader ? 0 : 1)), // account for the header with + 1
background: theme.colors.emphasize(theme.colors.background.primary, 0.015),
paddingLeft: EXPANDER_WIDTH,
position: 'absolute',
bottom: 0,
};
return (
<div style={subTableStyle}>
<Table
data={rowSubData}
width={width - EXPANDER_WIDTH}
height={tableStyles.rowHeight * (rowSubData.length + 1)}
noHeader={noHeader}
/>
</div>
);
}
}
return null;
}, },
[state.expanded, subData, tableStyles.rowHeight, theme.colors, width] [state.pageIndex, state.pageSize]
); );
const RenderRow = useCallback( const RenderRow = useCallback(
@@ -247,10 +220,20 @@ export const Table = memo((props: Props) => {
prepareRow(row); prepareRow(row);
const expandedRowStyle = state.expanded[row.index] ? css({ '&:hover': { background: 'inherit' } }) : {};
return ( return (
<div {...row.getRowProps({ style })} className={tableStyles.row}> <div {...row.getRowProps({ style })} className={cx(tableStyles.row, expandedRowStyle)}>
{/*add the subtable to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/} {/*add the nested data to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/}
{renderSubTable(rowIndex)} {nestedDataField && state.expanded[row.index] && (
<ExpandedRow
nestedData={nestedDataField}
tableStyles={tableStyles}
rowIndex={row.index}
width={width}
cellHeight={cellHeight}
/>
)}
{row.cells.map((cell: Cell, index: number) => ( {row.cells.map((cell: Cell, index: number) => (
<TableCell <TableCell
key={index} key={index}
@@ -266,7 +249,20 @@ export const Table = memo((props: Props) => {
</div> </div>
); );
}, },
[onCellFilterAdded, page, enablePagination, prepareRow, rows, tableStyles, renderSubTable, timeRange, data] [
rows,
enablePagination,
prepareRow,
tableStyles,
nestedDataField,
page,
onCellFilterAdded,
timeRange,
data,
width,
cellHeight,
state.expanded,
]
); );
const onNavigate = useCallback( const onNavigate = useCallback(
@@ -303,13 +299,11 @@ export const Table = memo((props: Props) => {
} }
const getItemSize = (index: number): number => { const getItemSize = (index: number): number => {
if (state.expanded[index]) { const indexForPagination = rowIndexForPagination(index);
const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === index); if (state.expanded[indexForPagination] && nestedDataField) {
if (rowSubData) { return getExpandedRowHeight(nestedDataField, indexForPagination, tableStyles);
const noHeader = !!rowSubData.meta?.custom?.noHeader;
return tableStyles.rowHeight * (rowSubData.length + 1 + (noHeader ? 0 : 1)); // account for the header and the row data with + 1 + 1
}
} }
return tableStyles.rowHeight; return tableStyles.rowHeight;
}; };
@@ -339,8 +333,8 @@ export const Table = memo((props: Props) => {
<div ref={variableSizeListScrollbarRef}> <div ref={variableSizeListScrollbarRef}>
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true}> <CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true}>
<VariableSizeList <VariableSizeList
// This component needs an unmount/remount when row height changes // This component needs an unmount/remount when row height or page changes
key={tableStyles.rowHeight} key={tableStyles.rowHeight + state.pageIndex}
height={listHeight} height={listHeight}
itemCount={itemCount} itemCount={itemCount}
itemSize={getItemSize} itemSize={getItemSize}
@@ -46,8 +46,30 @@ export function useResetVariableListSizeCache(
) { ) {
useEffect(() => { useEffect(() => {
if (extendedState.lastExpandedIndex !== undefined) { if (extendedState.lastExpandedIndex !== undefined) {
listRef.current?.resetAfterIndex(Math.max(extendedState.lastExpandedIndex - 1, 0)); // Gets the expanded row with the lowest index. Needed to reset all expanded row heights from that index on
let resetIndex = extendedState.lastExpandedIndex;
const expandedIndexes = Object.keys(extendedState.expanded);
if (expandedIndexes.length > 0) {
const lowestExpandedIndex = parseInt(expandedIndexes[0], 10);
if (!isNaN(lowestExpandedIndex)) {
resetIndex = Math.min(resetIndex, lowestExpandedIndex);
}
}
const index =
extendedState.pageIndex === 0
? resetIndex - 1
: resetIndex - extendedState.pageIndex - extendedState.pageIndex * extendedState.pageSize;
listRef.current?.resetAfterIndex(Math.max(index, 0));
return; return;
} }
}, [extendedState.lastExpandedIndex, extendedState.toggleRowExpandedCounter, listRef, data]); }, [
extendedState.lastExpandedIndex,
extendedState.toggleRowExpandedCounter,
extendedState.pageIndex,
extendedState.pageSize,
listRef,
data,
extendedState.expanded,
]);
} }
@@ -81,8 +81,6 @@ export interface Props {
footerValues?: FooterItem[]; footerValues?: FooterItem[];
enablePagination?: boolean; enablePagination?: boolean;
cellHeight?: schema.TableCellHeight; cellHeight?: schema.TableCellHeight;
/** @alpha */
subData?: DataFrame[];
/** @alpha Used by SparklineCell when provided */ /** @alpha Used by SparklineCell when provided */
timeRange?: TimeRange; timeRange?: TimeRange;
} }
@@ -102,7 +102,7 @@ export function getColumns(
for (const [fieldIndex, field] of data.fields.entries()) { for (const [fieldIndex, field] of data.fields.entries()) {
const fieldTableOptions = (field.config.custom || {}) as TableFieldOptions; const fieldTableOptions = (field.config.custom || {}) as TableFieldOptions;
if (fieldTableOptions.hidden) { if (fieldTableOptions.hidden || field.type === FieldType.nestedFrames) {
continue; continue;
} }
@@ -476,7 +476,7 @@ function addMissingColumnIndex(columns: Array<{ id: string; field?: Field } | un
const missingIndex = columns.findIndex((field, index) => field?.id !== String(index)); const missingIndex = columns.findIndex((field, index) => field?.id !== String(index));
// Base case // Base case
if (missingIndex === -1) { if (missingIndex === -1 || columns[missingIndex]?.id === 'expander') {
return; return;
} }
@@ -57,24 +57,19 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
} }
} }
getMainFrame(frames: DataFrame[] | null) {
return frames?.find((df) => df.meta?.custom?.parentRowIndex === undefined) || frames?.[0];
}
onChangeResultsStyle = (resultsStyle: TableResultsStyle) => { onChangeResultsStyle = (resultsStyle: TableResultsStyle) => {
this.setState({ resultsStyle }); this.setState({ resultsStyle });
}; };
getTableHeight() { getTableHeight() {
const { tableResult } = this.props; const { tableResult } = this.props;
const mainFrame = this.getMainFrame(tableResult);
if (!mainFrame || mainFrame.length === 0) { if (!tableResult || tableResult.length === 0) {
return 200; return 200;
} }
// tries to estimate table height // tries to estimate table height
return Math.max(Math.min(600, mainFrame.length * 35) + 35); return Math.max(Math.min(600, tableResult[0].length * 35) + 35);
} }
renderLabel = () => { renderLabel = () => {
@@ -134,8 +129,10 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
}); });
} }
const mainFrame = this.getMainFrame(dataFrames); const frames = dataFrames?.filter(
const subFrames = dataFrames?.filter((df) => df.meta?.custom?.parentRowIndex !== undefined); (frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
const label = this.state?.resultsStyle !== undefined ? this.renderLabel() : 'Table'; const label = this.state?.resultsStyle !== undefined ? this.renderLabel() : 'Table';
// Render table as default if resultsStyle is not set. // Render table as default if resultsStyle is not set.
@@ -143,22 +140,21 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
return ( return (
<Collapse label={label} loading={loading} isOpen> <Collapse label={label} loading={loading} isOpen>
{mainFrame?.length && ( {frames?.length && (
<> <>
{renderTable && ( {renderTable && (
<Table <Table
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
data={mainFrame} data={frames[0]}
subData={subFrames}
width={tableWidth} width={tableWidth}
height={height} height={height}
onCellFilterAdded={onCellFilterAdded} onCellFilterAdded={onCellFilterAdded}
/> />
)} )}
{this.state?.resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={mainFrame} />} {this.state?.resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={frames[0]} />}
</> </>
)} )}
{!mainFrame?.length && <MetaInfoText metaItems={[{ value: '0 series returned' }]} />} {!frames?.length && <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
</Collapse> </Collapse>
); );
} }
@@ -1,9 +1,13 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { applyFieldOverrides, TimeZone, SplitOpen, DataFrame, LoadingState } from '@grafana/data'; import { applyFieldOverrides, TimeZone, SplitOpen, DataFrame, LoadingState, FieldType } from '@grafana/data';
import { Table, AdHocFilterItem, PanelChrome } from '@grafana/ui'; import { Table, AdHocFilterItem, PanelChrome } from '@grafana/ui';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
import {
hasDeprecatedParentRowIndex,
migrateFromParentRowIndexToNestedFrames,
} from 'app/plugins/panel/table/migrations';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { ExploreItemState } from 'app/types/explore'; import { ExploreItemState } from 'app/types/explore';
@@ -34,11 +38,9 @@ const connector = connect(mapStateToProps, {});
type Props = TableContainerProps & ConnectedProps<typeof connector>; type Props = TableContainerProps & ConnectedProps<typeof connector>;
export class TableContainer extends PureComponent<Props> { export class TableContainer extends PureComponent<Props> {
getMainFrames(frames: DataFrame[] | null) { hasSubFrames = (data: DataFrame) => data.fields.some((f) => f.type === FieldType.nestedFrames);
return frames?.filter((df) => df.meta?.custom?.parentRowIndex === undefined) || [frames?.[0]];
}
getTableHeight(rowCount: number, hasSubFrames = true) { getTableHeight(rowCount: number, hasSubFrames: boolean) {
if (rowCount === 0) { if (rowCount === 0) {
return 200; return 200;
} }
@@ -50,8 +52,9 @@ export class TableContainer extends PureComponent<Props> {
render() { render() {
const { loading, onCellFilterAdded, tableResult, width, splitOpenFn, range, ariaLabel, timeZone } = this.props; const { loading, onCellFilterAdded, tableResult, width, splitOpenFn, range, ariaLabel, timeZone } = this.props;
let dataFrames = tableResult; let dataFrames = hasDeprecatedParentRowIndex(tableResult)
? migrateFromParentRowIndexToNestedFrames(tableResult)
: tableResult;
const dataLinkPostProcessor = exploreDataLinkPostProcessorFactory(splitOpenFn, range); const dataLinkPostProcessor = exploreDataLinkPostProcessorFactory(splitOpenFn, range);
if (dataFrames?.length) { if (dataFrames?.length) {
@@ -68,40 +71,31 @@ export class TableContainer extends PureComponent<Props> {
}); });
} }
// move dataframes to be grouped by table, with optional sub-tables for a row const frames = dataFrames?.filter(
const tableData: Array<{ main: DataFrame; sub?: DataFrame[] }> = [];
const mainFrames = this.getMainFrames(dataFrames).filter(
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0 (frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
); );
mainFrames?.forEach((frame) => {
const subFrames =
dataFrames?.filter((df) => frame.refId === df.refId && df.meta?.custom?.parentRowIndex !== undefined) ||
undefined;
tableData.push({ main: frame, sub: subFrames });
});
return ( return (
<> <>
{tableData.length === 0 && ( {frames && frames.length === 0 && (
<PanelChrome title={'Table'} width={width} height={200}> <PanelChrome title={'Table'} width={width} height={200}>
{() => <MetaInfoText metaItems={[{ value: '0 series returned' }]} />} {() => <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
</PanelChrome> </PanelChrome>
)} )}
{tableData.length > 0 && {frames &&
tableData.map((data, i) => ( frames.length > 0 &&
frames.map((data, i) => (
<PanelChrome <PanelChrome
key={data.main.refId || `table-${i}`} key={data.refId || `table-${i}`}
title={tableData.length > 1 ? `Table - ${data.main.name || data.main.refId || i}` : 'Table'} title={dataFrames && dataFrames.length > 1 ? `Table - ${data.name || data.refId || i}` : 'Table'}
width={width} width={width}
height={this.getTableHeight(data.main.length, (data.sub?.length || 0) > 0)} height={this.getTableHeight(data.length, this.hasSubFrames(data))}
loadingState={loading ? LoadingState.Loading : undefined} loadingState={loading ? LoadingState.Loading : undefined}
> >
{(innerWidth, innerHeight) => ( {(innerWidth, innerHeight) => (
<Table <Table
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
data={data.main} data={data}
subData={data.sub}
width={innerWidth} width={innerWidth}
height={innerHeight} height={innerHeight}
onCellFilterAdded={onCellFilterAdded} onCellFilterAdded={onCellFilterAdded}
@@ -142,6 +142,52 @@ describe('createTableFrameFromTraceQlQuery()', () => {
expect(frame.fields[3].name).toBe('traceDuration'); expect(frame.fields[3].name).toBe('traceDuration');
expect(frame.fields[3].type).toBe('number'); expect(frame.fields[3].type).toBe('number');
expect(frame.fields[3].values[2]).toBe(44); expect(frame.fields[3].values[2]).toBe(44);
// Subframes field
expect(frame.fields[4].name).toBe('nested');
expect(frame.fields[4].type).toBe('nestedFrames');
// Single spanset
expect(frame.fields[4].values[0][0].fields[0].name).toBe('traceIdHidden');
expect(frame.fields[4].values[0][0].fields[0].values[0]).toBe('b1586c3c8c34d');
expect(frame.fields[4].values[0][0].fields[1].name).toBe('spanID');
expect(frame.fields[4].values[0][0].fields[1].values[0]).toBe('162a4adae63b61f1');
expect(frame.fields[4].values[0][0].fields[2].name).toBe('spanStartTime');
expect(frame.fields[4].values[0][0].fields[2].values[0]).toBe('2022-10-19 09:03:34');
expect(frame.fields[4].values[0][0].fields[4].name).toBe('http.method');
expect(frame.fields[4].values[0][0].fields[4].values[0]).toBe('GET');
expect(frame.fields[4].values[0][0].fields[5].name).toBe('service.name');
expect(frame.fields[4].values[0][0].fields[5].values[0]).toBe('db');
expect(frame.fields[4].values[0][0].fields[6].name).toBe('duration');
expect(frame.fields[4].values[0][0].fields[6].values[0]).toBe(545000);
// Multiple spansets - set 0
expect(frame.fields[4].values[1][0].fields[0].name).toBe('traceIdHidden');
expect(frame.fields[4].values[1][0].fields[0].values[0]).toBe('9161e77388f3e');
expect(frame.fields[4].values[1][0].fields[1].name).toBe('spanID');
expect(frame.fields[4].values[1][0].fields[1].values[0]).toBe('3b9a5c222d3ddd8f');
expect(frame.fields[4].values[1][0].fields[2].name).toBe('spanStartTime');
expect(frame.fields[4].values[1][0].fields[2].values[0]).toBe('2022-10-19 08:57:55');
expect(frame.fields[4].values[1][0].fields[4].name).toBe('by(resource.service.name)');
expect(frame.fields[4].values[1][0].fields[4].values[0]).toBe('db');
expect(frame.fields[4].values[1][0].fields[5].name).toBe('http.method');
expect(frame.fields[4].values[1][0].fields[5].values[0]).toBe('GET');
expect(frame.fields[4].values[1][0].fields[6].name).toBe('service.name');
expect(frame.fields[4].values[1][0].fields[6].values[0]).toBe('db');
expect(frame.fields[4].values[1][0].fields[7].name).toBe('duration');
expect(frame.fields[4].values[1][0].fields[7].values[0]).toBe(877000);
// Multiple spansets - set 1
expect(frame.fields[4].values[1][1].fields[0].name).toBe('traceIdHidden');
expect(frame.fields[4].values[1][1].fields[0].values[0]).toBe('9161e77388f3e');
expect(frame.fields[4].values[1][1].fields[1].name).toBe('spanID');
expect(frame.fields[4].values[1][1].fields[1].values[0]).toBe('894d90db6b5807f');
expect(frame.fields[4].values[1][1].fields[2].name).toBe('spanStartTime');
expect(frame.fields[4].values[1][1].fields[2].values[0]).toBe('2022-10-19 08:57:55');
expect(frame.fields[4].values[1][1].fields[4].name).toBe('by(resource.service.name)');
expect(frame.fields[4].values[1][1].fields[4].values[0]).toBe('app');
expect(frame.fields[4].values[1][1].fields[5].name).toBe('http.method');
expect(frame.fields[4].values[1][1].fields[5].values[0]).toBe('GET');
expect(frame.fields[4].values[1][1].fields[6].name).toBe('service.name');
expect(frame.fields[4].values[1][1].fields[6].values[0]).toBe('app');
expect(frame.fields[4].values[1][1].fields[7].name).toBe('duration');
expect(frame.fields[4].values[1][1].fields[7].values[0]).toBe(11073000);
}); });
}); });
@@ -14,10 +14,13 @@ import {
TraceSpanRow, TraceSpanRow,
dateTimeFormat, dateTimeFormat,
FieldDTO, FieldDTO,
createDataFrame,
getDisplayProcessor,
createTheme,
} from '@grafana/data'; } from '@grafana/data';
import { createGraphFrames } from './graphTransform'; import { createGraphFrames } from './graphTransform';
import { Span, TraceSearchMetadata } from './types'; import { Span, SpanAttributes, Spanset, TraceSearchMetadata } from './types';
export function createTableFrame( export function createTableFrame(
logsFrame: DataFrame, logsFrame: DataFrame,
@@ -565,7 +568,7 @@ function transformToTraceData(data: TraceSearchMetadata) {
return { return {
traceID: data.traceID, traceID: data.traceID,
startTime, startTime,
traceDuration: data.durationMs?.toString(), traceDuration: data.durationMs,
traceName, traceName,
}; };
} }
@@ -574,7 +577,7 @@ export function createTableFrameFromTraceQlQuery(
data: TraceSearchMetadata[], data: TraceSearchMetadata[],
instanceSettings: DataSourceInstanceSettings instanceSettings: DataSourceInstanceSettings
): DataFrame[] { ): DataFrame[] {
const frame = new MutableDataFrame({ const frame = createDataFrame({
name: 'Traces', name: 'Traces',
fields: [ fields: [
{ {
@@ -624,6 +627,10 @@ export function createTableFrameFromTraceQlQuery(
}, },
}, },
}, },
{
name: 'nested',
type: FieldType.nestedFrames,
},
], ],
meta: { meta: {
preferredVisualisationType: 'table', preferredVisualisationType: 'table',
@@ -633,33 +640,48 @@ export function createTableFrameFromTraceQlQuery(
if (!data?.length) { if (!data?.length) {
return [frame]; return [frame];
} }
frame.length = data.length;
const subDataFrames: DataFrame[] = []; data
const tableRows = data
// Show the most recent traces // Show the most recent traces
.sort((a, b) => parseInt(b?.startTimeUnixNano!, 10) / 1000000 - parseInt(a?.startTimeUnixNano!, 10) / 1000000) .sort((a, b) => parseInt(b?.startTimeUnixNano!, 10) / 1000000 - parseInt(a?.startTimeUnixNano!, 10) / 1000000)
.reduce((rows: TraceTableData[], trace, currentIndex) => { .forEach((trace) => {
const traceData: TraceTableData = transformToTraceData(trace); const traceData: TraceTableData = transformToTraceData(trace);
rows.push(traceData); frame.fields[0].values.push(traceData.traceID);
subDataFrames.push(traceSubFrame(trace, instanceSettings, currentIndex)); frame.fields[1].values.push(traceData.startTime);
return rows; frame.fields[2].values.push(traceData.traceName);
}, []); frame.fields[3].values.push(traceData.traceDuration);
for (const row of tableRows) { if (trace.spanSets) {
frame.add(row); frame.fields[4].values.push(
} trace.spanSets.map((spanSet: Spanset) => {
return traceSubFrame(trace, spanSet, instanceSettings);
})
);
} else if (trace.spanSet) {
frame.fields[4].values.push([traceSubFrame(trace, trace.spanSet, instanceSettings)]);
}
});
return [frame, ...subDataFrames]; return [frame];
} }
const traceSubFrame = ( const traceSubFrame = (
trace: TraceSearchMetadata, trace: TraceSearchMetadata,
instanceSettings: DataSourceInstanceSettings, spanSet: Spanset,
currentIndex: number instanceSettings: DataSourceInstanceSettings
): DataFrame => { ): DataFrame => {
const spanDynamicAttrs: Record<string, FieldDTO> = {}; const spanDynamicAttrs: Record<string, FieldDTO> = {};
let hasNameAttribute = false; let hasNameAttribute = false;
trace.spanSet?.spans.forEach((span) => {
spanSet.attributes?.map((attr) => {
spanDynamicAttrs[attr.key] = {
name: attr.key,
type: FieldType.string,
config: { displayNameFromDS: attr.key },
};
});
spanSet.spans.forEach((span) => {
if (span.name) { if (span.name) {
hasNameAttribute = true; hasNameAttribute = true;
} }
@@ -671,6 +693,7 @@ const traceSubFrame = (
}; };
}); });
}); });
const subFrame = new MutableDataFrame({ const subFrame = new MutableDataFrame({
fields: [ fields: [
{ {
@@ -724,7 +747,7 @@ const traceSubFrame = (
type: FieldType.string, type: FieldType.string,
config: { displayNameFromDS: 'Name', custom: { hidden: !hasNameAttribute } }, config: { displayNameFromDS: 'Name', custom: { hidden: !hasNameAttribute } },
}, },
...Object.values(spanDynamicAttrs), ...Object.values(spanDynamicAttrs).sort((a, b) => a.name.localeCompare(b.name)),
{ {
name: 'duration', name: 'duration',
type: FieldType.number, type: FieldType.number,
@@ -739,14 +762,16 @@ const traceSubFrame = (
], ],
meta: { meta: {
preferredVisualisationType: 'table', preferredVisualisationType: 'table',
custom: {
parentRowIndex: currentIndex,
},
}, },
}); });
trace.spanSet?.spans.forEach((span) => { const theme = createTheme();
subFrame.add(transformSpanToTraceData(span, trace.traceID)); for (const field of subFrame.fields) {
field.display = getDisplayProcessor({ field, theme });
}
spanSet.spans.forEach((span) => {
subFrame.add(transformSpanToTraceData(span, spanSet, trace.traceID));
}); });
return subFrame; return subFrame;
@@ -758,10 +783,10 @@ interface TraceTableData {
spanID?: string; spanID?: string;
startTime?: string; startTime?: string;
name?: string; name?: string;
traceDuration?: string; traceDuration?: number;
} }
function transformSpanToTraceData(span: Span, traceID: string): TraceTableData { function transformSpanToTraceData(span: Span, spanSet: Spanset, traceID: string): TraceTableData {
const spanStartTimeUnixMs = parseInt(span.startTimeUnixNano, 10) / 1000000; const spanStartTimeUnixMs = parseInt(span.startTimeUnixNano, 10) / 1000000;
let spanStartTime = dateTimeFormat(spanStartTimeUnixMs); let spanStartTime = dateTimeFormat(spanStartTimeUnixMs);
@@ -773,7 +798,15 @@ function transformSpanToTraceData(span: Span, traceID: string): TraceTableData {
name: span.name, name: span.name,
}; };
span.attributes?.forEach((attr) => { let attrs: SpanAttributes[] = [];
if (spanSet.attributes) {
attrs = attrs.concat(spanSet.attributes);
}
if (span.attributes) {
attrs = attrs.concat(span.attributes);
}
attrs.forEach((attr) => {
if (attr.value.boolValue || attr.value.Value?.bool_value) { if (attr.value.boolValue || attr.value.Value?.bool_value) {
data[attr.key] = attr.value.boolValue || attr.value.Value?.bool_value; data[attr.key] = attr.value.boolValue || attr.value.Value?.bool_value;
} }
@@ -2391,87 +2391,91 @@ export const traceQlResponse = {
rootTraceName: 'HTTP Client', rootTraceName: 'HTTP Client',
startTimeUnixNano: '1643342166678000000', startTimeUnixNano: '1643342166678000000',
durationMs: 93, durationMs: 93,
spanSet: { spanSets: [
spans: [ {
{ attributes: [
spanID: '3b9a5c222d3ddd8f', {
startTimeUnixNano: '1666187875397721000', key: 'by(resource.service.name)',
durationNanos: '877000', value: {
attributes: [ stringValue: 'db',
{
key: 'http.method',
value: {
stringValue: 'GET',
},
}, },
{ },
key: 'service.name', ],
value: { spans: [
stringValue: 'db', {
spanID: '3b9a5c222d3ddd8f',
startTimeUnixNano: '1666187875397721000',
durationNanos: '877000',
attributes: [
{
key: 'http.method',
value: {
stringValue: 'GET',
},
}, },
}, {
], key: 'service.name',
}, value: {
{ stringValue: 'db',
spanID: '894d90db6b5807f', },
startTimeUnixNano: '1666187875393293000',
durationNanos: '11073000',
attributes: [
{
key: 'http.method',
value: {
stringValue: 'GET',
}, },
],
},
],
matched: 1,
},
{
attributes: [
{
key: 'by(resource.service.name)',
value: {
stringValue: 'app',
}, },
{ },
key: 'service.name', ],
value: { spans: [
stringValue: 'app', {
spanID: '894d90db6b5807f',
startTimeUnixNano: '1666187875393293000',
durationNanos: '11073000',
attributes: [
{
key: 'http.method',
value: {
stringValue: 'GET',
},
}, },
}, {
], key: 'service.name',
}, value: {
{ stringValue: 'app',
spanID: 'd3284e9c5081aab', },
startTimeUnixNano: '1666187875393897000',
durationNanos: '10133000',
attributes: [
{
key: 'service.name',
value: {
stringValue: 'app',
}, },
}, ],
{ },
key: 'http.method', {
value: { spanID: 'd3284e9c5081aab',
stringValue: 'GET', startTimeUnixNano: '1666187875393897000',
durationNanos: '10133000',
attributes: [
{
key: 'service.name',
value: {
stringValue: 'app',
},
}, },
}, {
], key: 'http.method',
}, value: {
{ stringValue: 'GET',
spanID: '454785498fc8b1aa', },
startTimeUnixNano: '1666187875389957000',
durationNanos: '13953000',
attributes: [
{
key: 'http.method',
value: {
stringValue: 'GET',
}, },
}, ],
{ },
key: 'service.name', ],
value: { matched: 2,
stringValue: 'lb', },
}, ],
},
],
},
],
matched: 4,
},
}, },
{ {
traceID: '480691f7c6f20', traceID: '480691f7c6f20',
+21 -18
View File
@@ -1,4 +1,4 @@
import { DataSourceJsonData, KeyValue } from '@grafana/data/src'; import { DataSourceJsonData } from '@grafana/data/src';
import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings'; import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings';
import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings';
@@ -55,7 +55,8 @@ export type TraceSearchMetadata = {
rootTraceName: string; rootTraceName: string;
startTimeUnixNano?: string; startTimeUnixNano?: string;
durationMs?: number; durationMs?: number;
spanSet?: { spans: Span[] }; spanSet?: Spanset;
spanSets?: Spanset[];
}; };
export type SearchMetrics = { export type SearchMetrics = {
@@ -76,6 +77,22 @@ export enum SpanKind {
CONSUMER, CONSUMER,
} }
export type SpanAttributes = {
key: string;
value: {
stringValue?: string;
intValue?: string;
boolValue?: boolean;
doubleValue?: string;
Value?: {
string_value?: string;
int_value?: string;
bool_value?: boolean;
double_value?: string;
};
};
};
export type Span = { export type Span = {
durationNanos: string; durationNanos: string;
traceId?: string; traceId?: string;
@@ -86,26 +103,12 @@ export type Span = {
kind?: SpanKind; kind?: SpanKind;
startTimeUnixNano: string; startTimeUnixNano: string;
endTimeUnixNano?: string; endTimeUnixNano?: string;
attributes?: Array<{ attributes?: SpanAttributes[];
key: string;
value: {
stringValue?: string;
intValue?: string;
boolValue?: boolean;
doubleValue?: string;
Value?: {
string_value?: string;
int_value?: string;
bool_value?: boolean;
double_value?: string;
};
};
}>;
dropped_attributes_count?: number; dropped_attributes_count?: number;
}; };
export type Spanset = { export type Spanset = {
attributes: KeyValue[]; attributes?: SpanAttributes[];
spans: Span[]; spans: Span[];
}; };
+9 -11
View File
@@ -6,6 +6,7 @@ import { PanelDataErrorView } from '@grafana/runtime';
import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui'; import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui';
import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types'; import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
import { hasDeprecatedParentRowIndex, migrateFromParentRowIndexToNestedFrames } from './migrations';
import { Options } from './panelcfg.gen'; import { Options } from './panelcfg.gen';
interface Props extends PanelProps<Options> {} interface Props extends PanelProps<Options> {}
@@ -15,16 +16,15 @@ export function TablePanel(props: Props) {
const theme = useTheme2(); const theme = useTheme2();
const panelContext = usePanelContext(); const panelContext = usePanelContext();
const frames = data.series; const frames = hasDeprecatedParentRowIndex(data.series)
const mainFrames = frames.filter((f) => f.meta?.custom?.parentRowIndex === undefined); ? migrateFromParentRowIndexToNestedFrames(data.series)
const subFrames = frames.filter((f) => f.meta?.custom?.parentRowIndex !== undefined); : data.series;
const count = mainFrames?.length; const count = frames?.length;
const hasFields = mainFrames[0]?.fields.length; const hasFields = frames[0]?.fields.length;
const currentIndex = getCurrentFrameIndex(mainFrames, options); const currentIndex = getCurrentFrameIndex(frames, options);
const main = mainFrames[currentIndex]; const main = frames[currentIndex];
let tableHeight = height; let tableHeight = height;
let subData = subFrames;
if (!count || !hasFields) { if (!count || !hasFields) {
return <PanelDataErrorView panelId={id} fieldConfig={fieldConfig} data={data} />; return <PanelDataErrorView panelId={id} fieldConfig={fieldConfig} data={data} />;
@@ -35,7 +35,6 @@ export function TablePanel(props: Props) {
const padding = theme.spacing.gridSize; const padding = theme.spacing.gridSize;
tableHeight = height - inputHeight - padding; tableHeight = height - inputHeight - padding;
subData = subFrames.filter((f) => f.refId === main.refId);
} }
const tableElement = ( const tableElement = (
@@ -52,7 +51,6 @@ export function TablePanel(props: Props) {
onCellFilterAdded={panelContext.onAddAdHocFilter} onCellFilterAdded={panelContext.onAddAdHocFilter}
footerOptions={options.footer} footerOptions={options.footer}
enablePagination={options.footer?.enablePagination} enablePagination={options.footer?.enablePagination}
subData={subData}
cellHeight={options.cellHeight} cellHeight={options.cellHeight}
timeRange={timeRange} timeRange={timeRange}
/> />
@@ -62,7 +60,7 @@ export function TablePanel(props: Props) {
return tableElement; return tableElement;
} }
const names = mainFrames.map((frame, index) => { const names = frames.map((frame, index) => {
return { return {
label: getFrameDisplayName(frame), label: getFrameDisplayName(frame),
value: index, value: index,
@@ -1,6 +1,6 @@
import { PanelModel } from '@grafana/data'; import { createDataFrame, FieldType, PanelModel } from '@grafana/data';
import { tablePanelChangedHandler } from './migrations'; import { migrateFromParentRowIndexToNestedFrames, tablePanelChangedHandler } from './migrations';
describe('Table Migrations', () => { describe('Table Migrations', () => {
it('migrates transform out to core transforms', () => { it('migrates transform out to core transforms', () => {
@@ -268,4 +268,64 @@ describe('Table Migrations', () => {
} }
`); `);
}); });
it('migrates DataFrame[] from format using meta.custom.parentRowIndex to format using FieldType.nestedFrames', () => {
const mainFrame = (refId: string) => {
return createDataFrame({
refId,
fields: [
{
name: 'field',
type: FieldType.string,
config: {},
values: ['a', 'b', 'c'],
},
],
meta: {
preferredVisualisationType: 'table',
},
});
};
const subFrame = (index: number) => {
return createDataFrame({
refId: 'B',
fields: [
{
name: `field_${index}`,
type: FieldType.string,
config: {},
values: [`${index}_subA`, 'subB', 'subC'],
},
],
meta: {
preferredVisualisationType: 'table',
custom: {
parentRowIndex: index,
},
},
});
};
const oldFormat = [mainFrame('A'), mainFrame('B'), subFrame(0), subFrame(1)];
const newFormat = migrateFromParentRowIndexToNestedFrames(oldFormat);
expect(newFormat.length).toBe(2);
expect(newFormat[0].refId).toBe('A');
expect(newFormat[1].refId).toBe('B');
expect(newFormat[0].fields.length).toBe(1);
expect(newFormat[1].fields.length).toBe(2);
expect(newFormat[0].fields[0].name).toBe('field');
expect(newFormat[1].fields[0].name).toBe('field');
expect(newFormat[1].fields[1].name).toBe('nested');
expect(newFormat[1].fields[1].type).toBe(FieldType.nestedFrames);
expect(newFormat[1].fields[1].values.length).toBe(2);
expect(newFormat[1].fields[1].values[0][0].refId).toBe('B');
expect(newFormat[1].fields[1].values[1][0].refId).toBe('B');
expect(newFormat[1].fields[1].values[0][0].length).toBe(3);
expect(newFormat[1].fields[1].values[0][0].length).toBe(3);
expect(newFormat[1].fields[1].values[0][0].fields[0].name).toBe('field_0');
expect(newFormat[1].fields[1].values[1][0].fields[0].name).toBe('field_1');
expect(newFormat[1].fields[1].values[0][0].fields[0].values[0]).toBe('0_subA');
expect(newFormat[1].fields[1].values[1][0].fields[0].values[0]).toBe('1_subA');
});
}); });
+42 -1
View File
@@ -1,4 +1,4 @@
import { omitBy, isNil, isNumber, defaultTo } from 'lodash'; import { omitBy, isNil, isNumber, defaultTo, groupBy } from 'lodash';
import { import {
PanelModel, PanelModel,
@@ -7,6 +7,8 @@ import {
ThresholdsMode, ThresholdsMode,
ThresholdsConfig, ThresholdsConfig,
FieldConfig, FieldConfig,
DataFrame,
FieldType,
} from '@grafana/data'; } from '@grafana/data';
import { ReduceTransformerOptions } from '@grafana/data/src/transformations/transformers/reduce'; import { ReduceTransformerOptions } from '@grafana/data/src/transformations/transformers/reduce';
@@ -249,3 +251,42 @@ export const tablePanelChangedHandler = (
return {}; return {};
}; };
const getMainFrames = (frames: DataFrame[] | null) => {
return frames?.filter((df) => df.meta?.custom?.parentRowIndex === undefined) || [frames?.[0]];
};
/**
* In 9.3 meta.custom.parentRowIndex was introduced to support sub-tables.
* In 10.2 meta.custom.parentRowIndex was deprecated in favor of FieldType.nestedFrames, which supports multiple nested frames.
* Migrate DataFrame[] from using meta.custom.parentRowIndex to using FieldType.nestedFrames
*/
export const migrateFromParentRowIndexToNestedFrames = (frames: DataFrame[] | null) => {
const migratedFrames: DataFrame[] = [];
const mainFrames = getMainFrames(frames).filter(
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
mainFrames?.forEach((frame) => {
const subFrames = frames?.filter((df) => frame.refId === df.refId && df.meta?.custom?.parentRowIndex !== undefined);
const subFramesGrouped = groupBy(subFrames, (frame: DataFrame) => frame.meta?.custom?.parentRowIndex);
const subFramesByIndex = Object.keys(subFramesGrouped).map((key) => subFramesGrouped[key]);
const migratedFrame = { ...frame };
if (subFrames && subFrames.length > 0) {
migratedFrame.fields.push({
name: 'nested',
type: FieldType.nestedFrames,
config: {},
values: subFramesByIndex,
});
}
migratedFrames.push(migratedFrame);
});
return migratedFrames;
};
export const hasDeprecatedParentRowIndex = (frames: DataFrame[] | null) => {
return frames?.some((df) => df.meta?.custom?.parentRowIndex !== undefined);
};