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