From 8c2f439cd7eeb09a9c8ef8d29576be6424904f2b Mon Sep 17 00:00:00 2001 From: Joey <90795735+joey-grafana@users.noreply.github.com> Date: Thu, 10 Aug 2023 12:33:46 +0100 Subject: [PATCH] Table: Support display of multiple sub tables (#71953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 Co-authored-by: Piotr Jamróz Co-authored-by: Torkel Ödegaard --- .betterer.results | 4 + .../docker/blocks/tempo/docker-compose.yaml | 2 +- .../src/field/fieldOverrides.test.ts | 45 ++++++ .../grafana-data/src/field/fieldOverrides.ts | 30 ++++ packages/grafana-data/src/types/dataFrame.ts | 1 + .../InteractiveTable/ExpanderCell.tsx | 1 + .../src/components/Table/ExpandedRow.tsx | 86 ++++++++++ .../src/components/Table/RowExpander.tsx | 2 +- .../grafana-ui/src/components/Table/Table.mdx | 15 +- .../src/components/Table/Table.story.tsx | 119 +++++++------- .../src/components/Table/Table.test.tsx | 74 ++++++--- .../grafana-ui/src/components/Table/Table.tsx | 98 ++++++------ .../grafana-ui/src/components/Table/hooks.ts | 26 ++- .../grafana-ui/src/components/Table/types.ts | 2 - .../grafana-ui/src/components/Table/utils.ts | 4 +- .../RawPrometheus/RawPrometheusContainer.tsx | 24 ++- .../features/explore/Table/TableContainer.tsx | 44 +++-- .../tempo/resultTransformer.test.ts | 46 ++++++ .../datasource/tempo/resultTransformer.ts | 85 +++++++--- .../plugins/datasource/tempo/testResponse.ts | 150 +++++++++--------- public/app/plugins/datasource/tempo/types.ts | 39 ++--- public/app/plugins/panel/table/TablePanel.tsx | 20 ++- .../plugins/panel/table/migrations.test.ts | 64 +++++++- public/app/plugins/panel/table/migrations.ts | 43 ++++- 24 files changed, 711 insertions(+), 313 deletions(-) create mode 100644 packages/grafana-ui/src/components/Table/ExpandedRow.tsx diff --git a/.betterer.results b/.betterer.results index b4bffc4f568..08682acb6a7 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1182,6 +1182,10 @@ exports[`better eslint`] = { "packages/grafana-ui/src/components/Table/DefaultCell.tsx:5381": [ [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": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/devenv/docker/blocks/tempo/docker-compose.yaml b/devenv/docker/blocks/tempo/docker-compose.yaml index 94abe341ffb..ad2539924af 100644 --- a/devenv/docker/blocks/tempo/docker-compose.yaml +++ b/devenv/docker/blocks/tempo/docker-compose.yaml @@ -72,7 +72,7 @@ labels: namespace tempo: - image: grafana/tempo:main-dcf8a2a + image: grafana/tempo:latest command: - --config.file=/etc/tempo.yaml volumes: diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 74d9c503931..86acc680108 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -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', () => { const field: FieldConfig = { min: 0, diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 5abdb0943aa..bc2ceece08a 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -207,6 +207,36 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra options.timeZone, 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; diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index 2ef26c3eea8..fc6e913defa 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -19,6 +19,7 @@ export enum FieldType { enum = 'enum', other = 'other', // Object, Array, etc frame = 'frame', // DataFrame + nestedFrames = 'nestedFrames', // @alpha Nested DataFrames } /** diff --git a/packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx b/packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx index 10d74578db2..03261f08dc0 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/ExpanderCell.tsx @@ -22,6 +22,7 @@ export function ExpanderCell({ row, __rowID }: CellProps ); diff --git a/packages/grafana-ui/src/components/Table/ExpandedRow.tsx b/packages/grafana-ui/src/components/Table/ExpandedRow.tsx new file mode 100644 index 00000000000..fe012ac06f8 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/ExpandedRow.tsx @@ -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( +
+ + + ); + }); + + return
{subTables}
; +} + +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; +} diff --git a/packages/grafana-ui/src/components/Table/RowExpander.tsx b/packages/grafana-ui/src/components/Table/RowExpander.tsx index 535ac572fc7..17366aed255 100644 --- a/packages/grafana-ui/src/components/Table/RowExpander.tsx +++ b/packages/grafana-ui/src/components/Table/RowExpander.tsx @@ -16,7 +16,7 @@ export function RowExpander({ row, tableStyles }: Props) { ); diff --git a/packages/grafana-ui/src/components/Table/Table.mdx b/packages/grafana-ui/src/components/Table/Table.mdx index 023e3c65161..f4879965e2d 100644 --- a/packages/grafana-ui/src/components/Table/Table.mdx +++ b/packages/grafana-ui/src/components/Table/Table.mdx @@ -7,11 +7,18 @@ Used for displaying tabular data ## Sub-tables -Sub-tables are supported through the usage of the prop `subData` Dataframe array. -The frames are linked to each row using the following custom properties under `dataframe.meta.custom` +Sub-tables are supported by adding `FieldType.nestedFrames` to the field that contains the nested data in your dataframe. -- **parentRowIndex**: number - The index of the parent row in the main dataframe (under the `data` prop of the Table component) -- **noHeader**: boolean - Sets the noHeader of each sub-table +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. + +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 diff --git a/packages/grafana-ui/src/components/Table/Table.story.tsx b/packages/grafana-ui/src/components/Table/Table.story.tsx index c2eb314adf6..42861538bfa 100644 --- a/packages/grafana-ui/src/components/Table/Table.story.tsx +++ b/packages/grafana-ui/src/components/Table/Table.story.tsx @@ -42,7 +42,7 @@ const meta: Meta = { }, }; -function buildData(theme: GrafanaTheme2, config: Record): DataFrame { +function buildData(theme: GrafanaTheme2, config: Record, rows = 1000): DataFrame { const data = new MutableDataFrame({ fields: [ { name: 'Time', type: FieldType.time, values: [] }, // The time field @@ -87,7 +87,7 @@ function buildData(theme: GrafanaTheme2, config: Record): D field.config = merge(field.config, config[field.name]); } - for (let i = 0; i < 1000; i++) { + for (let i = 0; i < rows; i++) { data.appendRow([ new Date().getTime(), Math.random() * 2, @@ -100,59 +100,72 @@ function buildData(theme: GrafanaTheme2, config: Record): D return prepDataForStorybook([data], theme)[0]; } -function buildSubTablesData(theme: GrafanaTheme2, config: Record): DataFrame[] { - const frames: DataFrame[] = []; +function buildSubTablesData(theme: GrafanaTheme2, config: Record, rows: number): DataFrame { + const data = buildData(theme, {}, rows); + const allNestedFrames: DataFrame[][] = []; - for (let i = 0; i < 1000; i++) { - const data = new MutableDataFrame({ - meta: { - custom: { - parentRowIndex: i, - }, - }, - fields: [ - { name: 'Time', type: FieldType.time, values: [] }, // The time field - { - name: 'Quantity', - type: FieldType.number, - values: [], - config: { - decimals: 0, - custom: { - align: 'center', + for (let i = 0; i < rows; i++) { + const nestedFrames: DataFrame[] = []; + + for (let i = 0; i < Math.random() * 3; i++) { + const nestedData = new MutableDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [] }, // The time field + { + name: 'Quantity', + type: FieldType.number, + values: [], + config: { + decimals: 0, + custom: { + align: 'center', + }, }, }, - }, - { name: 'Quality', type: FieldType.string, values: [] }, // The time field - { - name: 'Progress', - type: FieldType.number, - values: [], - config: { - unit: 'percent', - min: 0, - max: 100, + { name: 'Quality', type: FieldType.string, values: [] }, // The time field + { + name: 'Progress', + type: FieldType.number, + values: [], + config: { + unit: 'percent', + min: 0, + max: 100, + }, }, - }, - ], - }); + ], + }); - for (const field of data.fields) { - field.config = merge(field.config, config[field.name]); + for (const field of nestedData.fields) { + 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++) { - data.appendRow([ - new Date().getTime(), - Math.random() * 2, - Math.random() > 0.7 ? 'Good' : 'Bad', - Math.random() * 100, - ]); - } - - frames.push(data); + allNestedFrames.push(prepDataForStorybook(nestedFrames, theme)); } - return prepDataForStorybook(frames, theme); + + data.fields = [ + ...data.fields, + { + name: 'nested', + type: FieldType.nestedFrames, + values: allNestedFrames, + config: {}, + }, + ]; + + return data; } function buildFooterData(data: DataFrame): FooterItem[] { @@ -257,19 +270,11 @@ Pagination.args = { export const SubTables: StoryFn = (args) => { const theme = useTheme2(); - const data = buildData(theme, {}); - const subData = buildSubTablesData(theme, { - Progress: { - custom: { - displayMode: 'gradient-gauge', - }, - thresholds: defaultThresholds, - }, - }); + const data = buildSubTablesData(theme, {}, 100); return ( -
+
); }; diff --git a/packages/grafana-ui/src/components/Table/Table.test.tsx b/packages/grafana-ui/src/components/Table/Table.test.tsx index 8cfa4a69529..8d73374bc10 100644 --- a/packages/grafana-ui/src/components/Table/Table.test.tsx +++ b/packages/grafana-ui/src/components/Table/Table.test.tsx @@ -58,6 +58,10 @@ function getDefaultDataFrame(): DataFrame { }, ], }); + return applyOverrides(dataFrame); +} + +function applyOverrides(dataFrame: DataFrame) { const dataFrames = applyFieldOverrides({ data: [dataFrame], 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 () => { - getTestContext({ - subData: new Array(getDefaultDataFrame().length).fill(0).map((i) => + const nestedFrame = (idx: number) => + applyOverrides( toDataFrame({ - name: 'A', + name: `nested_frame${idx}`, fields: [ { - name: 'number' + i, - type: FieldType.number, - values: [i, i, i], - config: { - custom: { - filterable: true, - }, - }, + name: `humidity_${idx}`, + type: FieldType.string, + values: [`3%_${idx}`, `17%_${idx}`], + }, + { + 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(screen.getAllByRole('columnheader')).toHaveLength(4); @@ -557,11 +571,27 @@ describe('Table', () => { ]); await userEvent.click(within(rows[1]).getByLabelText('Expand row')); - const rowsAfterClick = within(getTable()).getAllByRole('row'); - expect(within(rowsAfterClick[1]).getByRole('table')).toBeInTheDocument(); - expect(within(rowsAfterClick[1]).getByText(/number0/)).toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(8); + expect(getColumnHeader(/humidity_0/)).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(); }); }); }); diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index 6042f3df5e1..10932006521 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -1,3 +1,4 @@ +import { css, cx } from '@emotion/css'; import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, UIEventHandler } from 'react'; import { Cell, @@ -11,13 +12,14 @@ import { } from 'react-table'; import { VariableSizeList } from 'react-window'; -import { Field, ReducerID } from '@grafana/data'; +import { Field, FieldType, ReducerID } from '@grafana/data'; import { TableCellHeight } from '@grafana/schema'; import { useTheme2 } from '../../themes'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { Pagination } from '../Pagination/Pagination'; +import { getExpandedRowHeight, ExpandedRow } from './ExpandedRow'; import { FooterRow } from './FooterRow'; import { HeaderRow } from './HeaderRow'; import { TableCell } from './TableCell'; @@ -25,14 +27,7 @@ import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks import { getInitialState, useTableStateReducer } from './reducer'; import { useTableStyles } from './styles'; import { FooterItem, GrafanaTableState, Props } from './types'; -import { - getColumns, - sortCaseInsensitive, - sortNumber, - getFooterItems, - createFooterCalculationValues, - EXPANDER_WIDTH, -} from './utils'; +import { getColumns, sortCaseInsensitive, sortNumber, getFooterItems, createFooterCalculationValues } from './utils'; const COLUMN_MIN_WIDTH = 150; const FOOTER_ROW_HEIGHT = 36; @@ -41,7 +36,6 @@ export const Table = memo((props: Props) => { const { ariaLabel, data, - subData, height, onCellFilterAdded, width, @@ -106,10 +100,13 @@ export const Table = memo((props: Props) => { footerOptions.reducer[0] === ReducerID.count ); + const nestedDataField = data.fields.find((f) => f.type === FieldType.nestedFrames); + const hasNestedData = nestedDataField !== undefined; + // React-table column definitions const memoizedColumns = useMemo( - () => getColumns(data, width, columnMinWidth, !!subData?.length, footerItems, isCountRowsSet), - [data, width, columnMinWidth, footerItems, subData, isCountRowsSet] + () => getColumns(data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet), + [data, width, columnMinWidth, footerItems, hasNestedData, isCountRowsSet] ); // Internal react table state reducer @@ -207,35 +204,11 @@ export const Table = memo((props: Props) => { useResetVariableListSizeCache(extendedState, listRef, data); useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef); - const renderSubTable = useCallback( - (rowIndex: number) => { - if (state.expanded[rowIndex]) { - 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 ( -
-
- - ); - } - } - return null; + const rowIndexForPagination = useCallback( + (index: number) => { + return state.pageIndex * state.pageSize + index; }, - [state.expanded, subData, tableStyles.rowHeight, theme.colors, width] + [state.pageIndex, state.pageSize] ); const RenderRow = useCallback( @@ -247,10 +220,20 @@ export const Table = memo((props: Props) => { prepareRow(row); + const expandedRowStyle = state.expanded[row.index] ? css({ '&:hover': { background: 'inherit' } }) : {}; + return ( -
- {/*add the subtable to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/} - {renderSubTable(rowIndex)} +
+ {/*add the nested data to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/} + {nestedDataField && state.expanded[row.index] && ( + + )} {row.cells.map((cell: Cell, index: number) => ( {
); }, - [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( @@ -303,13 +299,11 @@ export const Table = memo((props: Props) => { } const getItemSize = (index: number): number => { - if (state.expanded[index]) { - const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === index); - if (rowSubData) { - 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 - } + const indexForPagination = rowIndexForPagination(index); + if (state.expanded[indexForPagination] && nestedDataField) { + return getExpandedRowHeight(nestedDataField, indexForPagination, tableStyles); } + return tableStyles.rowHeight; }; @@ -339,8 +333,8 @@ export const Table = memo((props: Props) => {
{ 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; } - }, [extendedState.lastExpandedIndex, extendedState.toggleRowExpandedCounter, listRef, data]); + }, [ + extendedState.lastExpandedIndex, + extendedState.toggleRowExpandedCounter, + extendedState.pageIndex, + extendedState.pageSize, + listRef, + data, + extendedState.expanded, + ]); } diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index 22c0d1e9296..24747c5514e 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -81,8 +81,6 @@ export interface Props { footerValues?: FooterItem[]; enablePagination?: boolean; cellHeight?: schema.TableCellHeight; - /** @alpha */ - subData?: DataFrame[]; /** @alpha Used by SparklineCell when provided */ timeRange?: TimeRange; } diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index 7bd0ca96807..52a9d0ecfa7 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -102,7 +102,7 @@ export function getColumns( for (const [fieldIndex, field] of data.fields.entries()) { const fieldTableOptions = (field.config.custom || {}) as TableFieldOptions; - if (fieldTableOptions.hidden) { + if (fieldTableOptions.hidden || field.type === FieldType.nestedFrames) { continue; } @@ -476,7 +476,7 @@ function addMissingColumnIndex(columns: Array<{ id: string; field?: Field } | un const missingIndex = columns.findIndex((field, index) => field?.id !== String(index)); // Base case - if (missingIndex === -1) { + if (missingIndex === -1 || columns[missingIndex]?.id === 'expander') { return; } diff --git a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx index a301bfc8578..b9784a66483 100644 --- a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx +++ b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx @@ -57,24 +57,19 @@ export class RawPrometheusContainer extends PureComponent df.meta?.custom?.parentRowIndex === undefined) || frames?.[0]; - } - onChangeResultsStyle = (resultsStyle: TableResultsStyle) => { this.setState({ resultsStyle }); }; getTableHeight() { const { tableResult } = this.props; - const mainFrame = this.getMainFrame(tableResult); - if (!mainFrame || mainFrame.length === 0) { + if (!tableResult || tableResult.length === 0) { return 200; } // 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 = () => { @@ -134,8 +129,10 @@ export class RawPrometheusContainer extends PureComponent df.meta?.custom?.parentRowIndex !== undefined); + const frames = dataFrames?.filter( + (frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0 + ); + const label = this.state?.resultsStyle !== undefined ? this.renderLabel() : 'Table'; // Render table as default if resultsStyle is not set. @@ -143,22 +140,21 @@ export class RawPrometheusContainer extends PureComponent - {mainFrame?.length && ( + {frames?.length && ( <> {renderTable && (
)} - {this.state?.resultsStyle === TABLE_RESULTS_STYLE.raw && } + {this.state?.resultsStyle === TABLE_RESULTS_STYLE.raw && } )} - {!mainFrame?.length && } + {!frames?.length && } ); } diff --git a/public/app/features/explore/Table/TableContainer.tsx b/public/app/features/explore/Table/TableContainer.tsx index 0be3439a309..220a1219936 100644 --- a/public/app/features/explore/Table/TableContainer.tsx +++ b/public/app/features/explore/Table/TableContainer.tsx @@ -1,9 +1,13 @@ import React, { PureComponent } from 'react'; 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 { config } from 'app/core/config'; +import { + hasDeprecatedParentRowIndex, + migrateFromParentRowIndexToNestedFrames, +} from 'app/plugins/panel/table/migrations'; import { StoreState } from 'app/types'; import { ExploreItemState } from 'app/types/explore'; @@ -34,11 +38,9 @@ const connector = connect(mapStateToProps, {}); type Props = TableContainerProps & ConnectedProps; export class TableContainer extends PureComponent { - getMainFrames(frames: DataFrame[] | null) { - return frames?.filter((df) => df.meta?.custom?.parentRowIndex === undefined) || [frames?.[0]]; - } + hasSubFrames = (data: DataFrame) => data.fields.some((f) => f.type === FieldType.nestedFrames); - getTableHeight(rowCount: number, hasSubFrames = true) { + getTableHeight(rowCount: number, hasSubFrames: boolean) { if (rowCount === 0) { return 200; } @@ -50,8 +52,9 @@ export class TableContainer extends PureComponent { render() { 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); if (dataFrames?.length) { @@ -68,40 +71,31 @@ export class TableContainer extends PureComponent { }); } - // move dataframes to be grouped by table, with optional sub-tables for a row - const tableData: Array<{ main: DataFrame; sub?: DataFrame[] }> = []; - const mainFrames = this.getMainFrames(dataFrames).filter( + const frames = dataFrames?.filter( (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 ( <> - {tableData.length === 0 && ( + {frames && frames.length === 0 && ( {() => } )} - {tableData.length > 0 && - tableData.map((data, i) => ( + {frames && + frames.length > 0 && + frames.map((data, i) => ( 1 ? `Table - ${data.main.name || data.main.refId || i}` : 'Table'} + key={data.refId || `table-${i}`} + title={dataFrames && dataFrames.length > 1 ? `Table - ${data.name || data.refId || i}` : 'Table'} 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} > {(innerWidth, innerHeight) => (
{ expect(frame.fields[3].name).toBe('traceDuration'); expect(frame.fields[3].type).toBe('number'); 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); }); }); diff --git a/public/app/plugins/datasource/tempo/resultTransformer.ts b/public/app/plugins/datasource/tempo/resultTransformer.ts index b9a6a91a903..43f3ef5022f 100644 --- a/public/app/plugins/datasource/tempo/resultTransformer.ts +++ b/public/app/plugins/datasource/tempo/resultTransformer.ts @@ -14,10 +14,13 @@ import { TraceSpanRow, dateTimeFormat, FieldDTO, + createDataFrame, + getDisplayProcessor, + createTheme, } from '@grafana/data'; import { createGraphFrames } from './graphTransform'; -import { Span, TraceSearchMetadata } from './types'; +import { Span, SpanAttributes, Spanset, TraceSearchMetadata } from './types'; export function createTableFrame( logsFrame: DataFrame, @@ -565,7 +568,7 @@ function transformToTraceData(data: TraceSearchMetadata) { return { traceID: data.traceID, startTime, - traceDuration: data.durationMs?.toString(), + traceDuration: data.durationMs, traceName, }; } @@ -574,7 +577,7 @@ export function createTableFrameFromTraceQlQuery( data: TraceSearchMetadata[], instanceSettings: DataSourceInstanceSettings ): DataFrame[] { - const frame = new MutableDataFrame({ + const frame = createDataFrame({ name: 'Traces', fields: [ { @@ -624,6 +627,10 @@ export function createTableFrameFromTraceQlQuery( }, }, }, + { + name: 'nested', + type: FieldType.nestedFrames, + }, ], meta: { preferredVisualisationType: 'table', @@ -633,33 +640,48 @@ export function createTableFrameFromTraceQlQuery( if (!data?.length) { return [frame]; } + frame.length = data.length; - const subDataFrames: DataFrame[] = []; - const tableRows = data + data // Show the most recent traces .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); - rows.push(traceData); - subDataFrames.push(traceSubFrame(trace, instanceSettings, currentIndex)); - return rows; - }, []); + frame.fields[0].values.push(traceData.traceID); + frame.fields[1].values.push(traceData.startTime); + frame.fields[2].values.push(traceData.traceName); + frame.fields[3].values.push(traceData.traceDuration); - for (const row of tableRows) { - frame.add(row); - } + if (trace.spanSets) { + 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 = ( trace: TraceSearchMetadata, - instanceSettings: DataSourceInstanceSettings, - currentIndex: number + spanSet: Spanset, + instanceSettings: DataSourceInstanceSettings ): DataFrame => { const spanDynamicAttrs: Record = {}; 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) { hasNameAttribute = true; } @@ -671,6 +693,7 @@ const traceSubFrame = ( }; }); }); + const subFrame = new MutableDataFrame({ fields: [ { @@ -724,7 +747,7 @@ const traceSubFrame = ( type: FieldType.string, config: { displayNameFromDS: 'Name', custom: { hidden: !hasNameAttribute } }, }, - ...Object.values(spanDynamicAttrs), + ...Object.values(spanDynamicAttrs).sort((a, b) => a.name.localeCompare(b.name)), { name: 'duration', type: FieldType.number, @@ -739,14 +762,16 @@ const traceSubFrame = ( ], meta: { preferredVisualisationType: 'table', - custom: { - parentRowIndex: currentIndex, - }, }, }); - trace.spanSet?.spans.forEach((span) => { - subFrame.add(transformSpanToTraceData(span, trace.traceID)); + const theme = createTheme(); + for (const field of subFrame.fields) { + field.display = getDisplayProcessor({ field, theme }); + } + + spanSet.spans.forEach((span) => { + subFrame.add(transformSpanToTraceData(span, spanSet, trace.traceID)); }); return subFrame; @@ -758,10 +783,10 @@ interface TraceTableData { spanID?: string; startTime?: 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; let spanStartTime = dateTimeFormat(spanStartTimeUnixMs); @@ -773,7 +798,15 @@ function transformSpanToTraceData(span: Span, traceID: string): TraceTableData { 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) { data[attr.key] = attr.value.boolValue || attr.value.Value?.bool_value; } diff --git a/public/app/plugins/datasource/tempo/testResponse.ts b/public/app/plugins/datasource/tempo/testResponse.ts index ecee2d745c4..dfba4ad019d 100644 --- a/public/app/plugins/datasource/tempo/testResponse.ts +++ b/public/app/plugins/datasource/tempo/testResponse.ts @@ -2391,87 +2391,91 @@ export const traceQlResponse = { rootTraceName: 'HTTP Client', startTimeUnixNano: '1643342166678000000', durationMs: 93, - spanSet: { - spans: [ - { - spanID: '3b9a5c222d3ddd8f', - startTimeUnixNano: '1666187875397721000', - durationNanos: '877000', - attributes: [ - { - key: 'http.method', - value: { - stringValue: 'GET', - }, + spanSets: [ + { + attributes: [ + { + key: 'by(resource.service.name)', + value: { + stringValue: 'db', }, - { - key: 'service.name', - value: { - stringValue: 'db', + }, + ], + spans: [ + { + spanID: '3b9a5c222d3ddd8f', + startTimeUnixNano: '1666187875397721000', + durationNanos: '877000', + attributes: [ + { + key: 'http.method', + value: { + stringValue: 'GET', + }, }, - }, - ], - }, - { - spanID: '894d90db6b5807f', - startTimeUnixNano: '1666187875393293000', - durationNanos: '11073000', - attributes: [ - { - key: 'http.method', - value: { - stringValue: 'GET', + { + key: 'service.name', + value: { + stringValue: 'db', + }, }, + ], + }, + ], + matched: 1, + }, + { + attributes: [ + { + key: 'by(resource.service.name)', + value: { + stringValue: 'app', }, - { - key: 'service.name', - value: { - stringValue: 'app', + }, + ], + spans: [ + { + spanID: '894d90db6b5807f', + startTimeUnixNano: '1666187875393293000', + durationNanos: '11073000', + attributes: [ + { + key: 'http.method', + value: { + stringValue: 'GET', + }, }, - }, - ], - }, - { - spanID: 'd3284e9c5081aab', - startTimeUnixNano: '1666187875393897000', - durationNanos: '10133000', - attributes: [ - { - key: 'service.name', - value: { - stringValue: 'app', + { + key: 'service.name', + value: { + stringValue: 'app', + }, }, - }, - { - key: 'http.method', - value: { - stringValue: 'GET', + ], + }, + { + spanID: 'd3284e9c5081aab', + startTimeUnixNano: '1666187875393897000', + durationNanos: '10133000', + attributes: [ + { + key: 'service.name', + value: { + stringValue: 'app', + }, }, - }, - ], - }, - { - spanID: '454785498fc8b1aa', - startTimeUnixNano: '1666187875389957000', - durationNanos: '13953000', - attributes: [ - { - key: 'http.method', - value: { - stringValue: 'GET', + { + key: 'http.method', + value: { + stringValue: 'GET', + }, }, - }, - { - key: 'service.name', - value: { - stringValue: 'lb', - }, - }, - ], - }, - ], - matched: 4, - }, + ], + }, + ], + matched: 2, + }, + ], }, { traceID: '480691f7c6f20', diff --git a/public/app/plugins/datasource/tempo/types.ts b/public/app/plugins/datasource/tempo/types.ts index 238a3b85ee7..a888b650bc1 100644 --- a/public/app/plugins/datasource/tempo/types.ts +++ b/public/app/plugins/datasource/tempo/types.ts @@ -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 { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; @@ -55,7 +55,8 @@ export type TraceSearchMetadata = { rootTraceName: string; startTimeUnixNano?: string; durationMs?: number; - spanSet?: { spans: Span[] }; + spanSet?: Spanset; + spanSets?: Spanset[]; }; export type SearchMetrics = { @@ -76,6 +77,22 @@ export enum SpanKind { 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 = { durationNanos: string; traceId?: string; @@ -86,26 +103,12 @@ export type Span = { kind?: SpanKind; startTimeUnixNano: string; endTimeUnixNano?: string; - attributes?: Array<{ - key: string; - value: { - stringValue?: string; - intValue?: string; - boolValue?: boolean; - doubleValue?: string; - Value?: { - string_value?: string; - int_value?: string; - bool_value?: boolean; - double_value?: string; - }; - }; - }>; + attributes?: SpanAttributes[]; dropped_attributes_count?: number; }; export type Spanset = { - attributes: KeyValue[]; + attributes?: SpanAttributes[]; spans: Span[]; }; diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index 45ea21b0443..d9d4e85ec40 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -6,6 +6,7 @@ import { PanelDataErrorView } from '@grafana/runtime'; import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui'; import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types'; +import { hasDeprecatedParentRowIndex, migrateFromParentRowIndexToNestedFrames } from './migrations'; import { Options } from './panelcfg.gen'; interface Props extends PanelProps {} @@ -15,16 +16,15 @@ export function TablePanel(props: Props) { const theme = useTheme2(); const panelContext = usePanelContext(); - const frames = data.series; - const mainFrames = frames.filter((f) => f.meta?.custom?.parentRowIndex === undefined); - const subFrames = frames.filter((f) => f.meta?.custom?.parentRowIndex !== undefined); - const count = mainFrames?.length; - const hasFields = mainFrames[0]?.fields.length; - const currentIndex = getCurrentFrameIndex(mainFrames, options); - const main = mainFrames[currentIndex]; + const frames = hasDeprecatedParentRowIndex(data.series) + ? migrateFromParentRowIndexToNestedFrames(data.series) + : data.series; + const count = frames?.length; + const hasFields = frames[0]?.fields.length; + const currentIndex = getCurrentFrameIndex(frames, options); + const main = frames[currentIndex]; let tableHeight = height; - let subData = subFrames; if (!count || !hasFields) { return ; @@ -35,7 +35,6 @@ export function TablePanel(props: Props) { const padding = theme.spacing.gridSize; tableHeight = height - inputHeight - padding; - subData = subFrames.filter((f) => f.refId === main.refId); } const tableElement = ( @@ -52,7 +51,6 @@ export function TablePanel(props: Props) { onCellFilterAdded={panelContext.onAddAdHocFilter} footerOptions={options.footer} enablePagination={options.footer?.enablePagination} - subData={subData} cellHeight={options.cellHeight} timeRange={timeRange} /> @@ -62,7 +60,7 @@ export function TablePanel(props: Props) { return tableElement; } - const names = mainFrames.map((frame, index) => { + const names = frames.map((frame, index) => { return { label: getFrameDisplayName(frame), value: index, diff --git a/public/app/plugins/panel/table/migrations.test.ts b/public/app/plugins/panel/table/migrations.test.ts index 81b3286a6c3..fd952995ce5 100644 --- a/public/app/plugins/panel/table/migrations.test.ts +++ b/public/app/plugins/panel/table/migrations.test.ts @@ -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', () => { 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'); + }); }); diff --git a/public/app/plugins/panel/table/migrations.ts b/public/app/plugins/panel/table/migrations.ts index f807a497e22..fa3fefaa063 100644 --- a/public/app/plugins/panel/table/migrations.ts +++ b/public/app/plugins/panel/table/migrations.ts @@ -1,4 +1,4 @@ -import { omitBy, isNil, isNumber, defaultTo } from 'lodash'; +import { omitBy, isNil, isNumber, defaultTo, groupBy } from 'lodash'; import { PanelModel, @@ -7,6 +7,8 @@ import { ThresholdsMode, ThresholdsConfig, FieldConfig, + DataFrame, + FieldType, } from '@grafana/data'; import { ReduceTransformerOptions } from '@grafana/data/src/transformations/transformers/reduce'; @@ -249,3 +251,42 @@ export const tablePanelChangedHandler = ( 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); +};