mirror of
https://github.com/grafana/grafana.git
synced 2025-08-02 23:35:15 +08:00
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:
@ -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"]
|
||||
],
|
||||
|
@ -72,7 +72,7 @@
|
||||
labels: namespace
|
||||
|
||||
tempo:
|
||||
image: grafana/tempo:main-dcf8a2a
|
||||
image: grafana/tempo:latest
|
||||
command:
|
||||
- --config.file=/etc/tempo.yaml
|
||||
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', () => {
|
||||
const field: FieldConfig = {
|
||||
min: 0,
|
||||
|
@ -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;
|
||||
|
@ -19,6 +19,7 @@ export enum FieldType {
|
||||
enum = 'enum',
|
||||
other = 'other', // Object, Array, etc
|
||||
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}
|
||||
// @ts-expect-error same as the line above
|
||||
{...row.getToggleRowExpandedProps()}
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
86
packages/grafana-ui/src/components/Table/ExpandedRow.tsx
Normal file
86
packages/grafana-ui/src/components/Table/ExpandedRow.tsx
Normal file
@ -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
|
||||
aria-label={row.isExpanded ? 'Collapse row' : 'Expand row'}
|
||||
name={row.isExpanded ? 'angle-down' : 'angle-right'}
|
||||
size="xl"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -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
|
||||
|
||||
|
@ -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({
|
||||
fields: [
|
||||
{ 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]);
|
||||
}
|
||||
|
||||
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<string, FieldConfig>): D
|
||||
return prepDataForStorybook([data], theme)[0];
|
||||
}
|
||||
|
||||
function buildSubTablesData(theme: GrafanaTheme2, config: Record<string, FieldConfig>): DataFrame[] {
|
||||
const frames: DataFrame[] = [];
|
||||
function buildSubTablesData(theme: GrafanaTheme2, config: Record<string, FieldConfig>, 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<typeof Table> = (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 (
|
||||
<DashboardStoryCanvas>
|
||||
<Table {...args} data={data} subData={subData} />
|
||||
<Table {...args} data={data} />
|
||||
</DashboardStoryCanvas>
|
||||
);
|
||||
};
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 (
|
||||
<div style={subTableStyle}>
|
||||
<Table
|
||||
data={rowSubData}
|
||||
width={width - EXPANDER_WIDTH}
|
||||
height={tableStyles.rowHeight * (rowSubData.length + 1)}
|
||||
noHeader={noHeader}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
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 (
|
||||
<div {...row.getRowProps({ style })} className={tableStyles.row}>
|
||||
{/*add the subtable to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/}
|
||||
{renderSubTable(rowIndex)}
|
||||
<div {...row.getRowProps({ style })} className={cx(tableStyles.row, expandedRowStyle)}>
|
||||
{/*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] && (
|
||||
<ExpandedRow
|
||||
nestedData={nestedDataField}
|
||||
tableStyles={tableStyles}
|
||||
rowIndex={row.index}
|
||||
width={width}
|
||||
cellHeight={cellHeight}
|
||||
/>
|
||||
)}
|
||||
{row.cells.map((cell: Cell, index: number) => (
|
||||
<TableCell
|
||||
key={index}
|
||||
@ -266,7 +249,20 @@ export const Table = memo((props: Props) => {
|
||||
</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(
|
||||
@ -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) => {
|
||||
<div ref={variableSizeListScrollbarRef}>
|
||||
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true}>
|
||||
<VariableSizeList
|
||||
// This component needs an unmount/remount when row height changes
|
||||
key={tableStyles.rowHeight}
|
||||
// This component needs an unmount/remount when row height or page changes
|
||||
key={tableStyles.rowHeight + state.pageIndex}
|
||||
height={listHeight}
|
||||
itemCount={itemCount}
|
||||
itemSize={getItemSize}
|
||||
|
@ -46,8 +46,30 @@ export function useResetVariableListSizeCache(
|
||||
) {
|
||||
useEffect(() => {
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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) => {
|
||||
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<Props, PrometheusConta
|
||||
});
|
||||
}
|
||||
|
||||
const mainFrame = this.getMainFrame(dataFrames);
|
||||
const subFrames = dataFrames?.filter((df) => 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<Props, PrometheusConta
|
||||
|
||||
return (
|
||||
<Collapse label={label} loading={loading} isOpen>
|
||||
{mainFrame?.length && (
|
||||
{frames?.length && (
|
||||
<>
|
||||
{renderTable && (
|
||||
<Table
|
||||
ariaLabel={ariaLabel}
|
||||
data={mainFrame}
|
||||
subData={subFrames}
|
||||
data={frames[0]}
|
||||
width={tableWidth}
|
||||
height={height}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -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<typeof connector>;
|
||||
|
||||
export class TableContainer extends PureComponent<Props> {
|
||||
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<Props> {
|
||||
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<Props> {
|
||||
});
|
||||
}
|
||||
|
||||
// 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 && (
|
||||
<PanelChrome title={'Table'} width={width} height={200}>
|
||||
{() => <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
|
||||
</PanelChrome>
|
||||
)}
|
||||
{tableData.length > 0 &&
|
||||
tableData.map((data, i) => (
|
||||
{frames &&
|
||||
frames.length > 0 &&
|
||||
frames.map((data, i) => (
|
||||
<PanelChrome
|
||||
key={data.main.refId || `table-${i}`}
|
||||
title={tableData.length > 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) => (
|
||||
<Table
|
||||
ariaLabel={ariaLabel}
|
||||
data={data.main}
|
||||
subData={data.sub}
|
||||
data={data}
|
||||
width={innerWidth}
|
||||
height={innerHeight}
|
||||
onCellFilterAdded={onCellFilterAdded}
|
||||
|
@ -142,6 +142,52 @@ describe('createTableFrameFromTraceQlQuery()', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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<string, FieldDTO> = {};
|
||||
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;
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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[];
|
||||
};
|
||||
|
||||
|
@ -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<Options> {}
|
||||
@ -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 <PanelDataErrorView panelId={id} fieldConfig={fieldConfig} data={data} />;
|
||||
@ -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,
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
};
|
||||
|
Reference in New Issue
Block a user