Prometheus: New instant query results view in Explore (#60479)

Add new default instant query UI option for prometheus users in Explore.

Co-authored-by: Beto Muniz <contato@betomuniz.com>
Co-authored-by: Giordano Ricci <me@giordanoricci.com>
This commit is contained in:
Galen Kistler 2023-01-04 10:46:03 -06:00 committed by GitHub
parent 0e7640475f
commit 0e265245eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1195 additions and 14 deletions

View File

@ -3743,9 +3743,6 @@ exports[`better eslint`] = {
"public/app/features/explore/RichHistory/RichHistoryStarredTab.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/explore/TableContainer.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/explore/TraceView/TraceView.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -21,7 +21,15 @@ export enum LoadingState {
}
// Should be kept in sync with grafana-plugin-sdk-go/data/frame_meta.go
export const preferredVisualizationTypes = ['graph', 'table', 'logs', 'trace', 'nodeGraph', 'flamegraph'] as const;
export const preferredVisualizationTypes = [
'graph',
'table',
'logs',
'trace',
'nodeGraph',
'flamegraph',
'rawPrometheus',
] as const;
export type PreferredVisualisationType = typeof preferredVisualizationTypes[number];
/**

View File

@ -86,6 +86,7 @@ const dummyProps: Props = {
splitted: false,
isFromCompactUrl: false,
eventBus: new EventBusSrv(),
showRawPrometheus: false,
};
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {

View File

@ -40,6 +40,7 @@ import { NoData } from './NoData';
import { NoDataSourceCallToAction } from './NoDataSourceCallToAction';
import { NodeGraphContainer } from './NodeGraphContainer';
import { QueryRows } from './QueryRows';
import RawPrometheusContainer from './RawPrometheusContainer';
import { ResponseErrorContainer } from './ResponseErrorContainer';
import RichHistoryContainer from './RichHistory/RichHistoryContainer';
import { SecondaryActions } from './SecondaryActions';
@ -316,6 +317,21 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
);
}
renderRawPrometheus(width: number) {
const { exploreId, datasourceInstance, timeZone } = this.props;
return (
<RawPrometheusContainer
showRawPrometheus={true}
ariaLabel={selectors.pages.Explore.General.table}
width={width}
exploreId={exploreId}
onCellFilterAdded={datasourceInstance?.modifyQuery ? this.onCellFilterAdded : undefined}
timeZone={timeZone}
splitOpenFn={this.onSplitOpen('table')}
/>
);
}
renderLogsPanel(width: number) {
const { exploreId, syncedTimes, theme, queryResponse } = this.props;
const spacing = parseInt(theme.spacing(2).slice(0, -2), 10);
@ -388,6 +404,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
theme,
showMetrics,
showTable,
showRawPrometheus,
showLogs,
showTrace,
showNodeGraph,
@ -410,6 +427,7 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
queryResponse.nodeGraphFrames,
queryResponse.flameGraphFrames,
queryResponse.tableFrames,
queryResponse.rawPrometheusFrames,
queryResponse.traceFrames,
].every((e) => e.length === 0);
@ -458,6 +476,9 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
{showMetrics && graphResult && (
<ErrorBoundaryAlert>{this.renderGraphPanel(width)}</ErrorBoundaryAlert>
)}
{showRawPrometheus && (
<ErrorBoundaryAlert>{this.renderRawPrometheus(width)}</ErrorBoundaryAlert>
)}
{showTable && <ErrorBoundaryAlert>{this.renderTablePanel(width)}</ErrorBoundaryAlert>}
{showLogs && <ErrorBoundaryAlert>{this.renderLogsPanel(width)}</ErrorBoundaryAlert>}
{showNodeGraph && <ErrorBoundaryAlert>{this.renderNodeGraphPanel()}</ErrorBoundaryAlert>}
@ -518,6 +539,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showFlameGraph,
loading,
isFromCompactUrl,
showRawPrometheus,
} = item;
return {
@ -537,6 +559,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showTable,
showTrace,
showNodeGraph,
showRawPrometheus,
showFlameGraph,
splitted: isSplit(state),
loading,

View File

@ -51,9 +51,11 @@ const setup = (propOverrides = {}) => {
traceFrames: [],
nodeGraphFrames: [],
flameGraphFrames: [],
rawPrometheusFrames: [],
graphResult: null,
logsResult: null,
tableResult: null,
rawPrometheusResult: null,
},
runQueries: jest.fn(),
...propOverrides,

View File

@ -0,0 +1,38 @@
import { css } from '@emotion/css';
import React from 'react';
import { Field, GrafanaTheme2 } from '@grafana/data/';
import { useStyles2 } from '@grafana/ui/';
import { rawListItemColumnWidth } from './RawListItem';
const getItemLabelsStyles = (theme: GrafanaTheme2, expanded: boolean) => {
return {
valueNavigation: css`
width: ${rawListItemColumnWidth};
font-weight: bold;
`,
valueNavigationWrapper: css`
display: flex;
justify-content: flex-end;
`,
itemLabelsWrap: css`
${!expanded ? `border-bottom: 1px solid ${theme.colors.border.medium}` : ''};
`,
};
};
export const ItemLabels = ({ valueLabels, expanded }: { valueLabels: Field[]; expanded: boolean }) => {
const styles = useStyles2((theme) => getItemLabelsStyles(theme, expanded));
return (
<div className={styles.itemLabelsWrap}>
<div className={styles.valueNavigationWrapper}>
{valueLabels.map((value, index) => (
<span className={styles.valueNavigation} key={value.name}>
{value.name}
</span>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { RawPrometheusListItemEmptyValue } from '../utils/getRawPrometheusListItemsFromDataFrame';
import { ItemValues } from './ItemValues';
import { RawListValue } from './RawListItem';
const value1 = 'value 1';
const value2 = 'value 2';
const defaultProps: {
totalNumberOfValues: number;
values: RawListValue[];
hideFieldsWithoutValues: boolean;
} = {
totalNumberOfValues: 3,
values: [
{
key: 'Value #A',
value: value1,
},
{
key: 'Value #B',
value: value2,
},
{
key: 'Value #C',
value: RawPrometheusListItemEmptyValue, // Empty value
},
],
hideFieldsWithoutValues: false,
};
describe('ItemValues', () => {
it('should render values, with empty values', () => {
const itemValues = render(<ItemValues {...defaultProps} />);
expect(screen.getByText(value1)).toBeVisible();
expect(screen.getByText(value2)).toBeVisible();
expect(itemValues?.baseElement?.children?.item(0)?.children?.item(0)?.children.length).toBe(3);
});
it('should render values, without empty values', () => {
const props = { ...defaultProps, hideFieldsWithoutValues: true };
const itemValues = render(<ItemValues {...props} />);
expect(screen.getByText(value1)).toBeVisible();
expect(screen.getByText(value2)).toBeVisible();
expect(itemValues?.baseElement?.children?.item(0)?.children?.item(0)?.children.length).toBe(2);
});
});

View File

@ -0,0 +1,72 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data/';
import { useStyles2 } from '@grafana/ui/';
import { RawPrometheusListItemEmptyValue } from '../utils/getRawPrometheusListItemsFromDataFrame';
import { rawListItemColumnWidth, rawListPaddingToHoldSpaceForCopyIcon, RawListValue } from './RawListItem';
const getStyles = (theme: GrafanaTheme2, totalNumberOfValues: number) => ({
rowWrapper: css`
position: relative;
min-width: ${rawListItemColumnWidth};
padding-right: 5px;
`,
rowValue: css`
white-space: nowrap;
overflow-x: auto;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
display: block;
padding-right: 10px;
&::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
&:before {
pointer-events: none;
content: '';
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: linear-gradient(to right, transparent calc(100% - 25px), ${theme.colors.background.primary});
}
`,
rowValuesWrap: css`
padding-left: ${rawListPaddingToHoldSpaceForCopyIcon};
width: calc(${totalNumberOfValues} * ${rawListItemColumnWidth});
display: flex;
`,
});
export const ItemValues = ({
totalNumberOfValues,
values,
hideFieldsWithoutValues,
}: {
totalNumberOfValues: number;
values: RawListValue[];
hideFieldsWithoutValues: boolean;
}) => {
const styles = useStyles2((theme) => getStyles(theme, totalNumberOfValues));
return (
<div role={'cell'} className={styles.rowValuesWrap}>
{values?.map((value) => {
if (hideFieldsWithoutValues && (value.value === undefined || value.value === RawPrometheusListItemEmptyValue)) {
return null;
}
return (
<span key={value.key} className={styles.rowWrapper}>
<span className={styles.rowValue}>{value.value}</span>
</span>
);
})}
</div>
);
};

View File

@ -0,0 +1,70 @@
import { render, screen, within } from '@testing-library/react';
import React from 'react';
import { FieldType, FormattedValue, toDataFrame } from '@grafana/data/src';
import RawListContainer, { RawListContainerProps } from './RawListContainer';
function getList(): HTMLElement {
return screen.getByRole('table');
}
const display = (input: string): FormattedValue => {
return {
text: input,
};
};
const dataFrame = toDataFrame({
name: 'A',
fields: [
{
name: 'Time',
type: FieldType.time,
values: [1609459200000, 1609470000000, 1609462800000, 1609466400000],
config: {
custom: {
filterable: false,
},
},
},
{
display: display,
name: 'text',
type: FieldType.string,
values: ['test_string_1', 'test_string_2', 'test_string_3', 'test_string_4'],
config: {
custom: {
filterable: false,
},
},
},
{
name: '__name__',
type: FieldType.string,
values: ['test_string_1', 'test_string_2', 'test_string_3', 'test_string_4'],
config: {
custom: {
filterable: false,
},
},
},
],
});
const defaultProps: RawListContainerProps = {
tableResult: dataFrame,
};
describe('RawListContainer', () => {
it('should render', () => {
render(<RawListContainer {...defaultProps} />);
expect(getList()).toBeInTheDocument();
const rows = within(getList()).getAllByRole('row');
expect(rows).toHaveLength(4);
rows.forEach((row, index) => {
expect(screen.getAllByText(`test_string_${index + 1}`)[0]).toBeVisible();
});
});
});

View File

@ -0,0 +1,167 @@
import { css } from '@emotion/css';
import { cloneDeep } from 'lodash';
import React, { useEffect, useRef, useState } from 'react';
import { useWindowSize } from 'react-use';
import { VariableSizeList as List } from 'react-window';
import { DataFrame, Field as DataFrameField } from '@grafana/data/';
import { Field, Switch } from '@grafana/ui/';
import {
getRawPrometheusListItemsFromDataFrame,
RawPrometheusListItemEmptyValue,
} from '../utils/getRawPrometheusListItemsFromDataFrame';
import { ItemLabels } from './ItemLabels';
import RawListItem from './RawListItem';
export type instantQueryRawVirtualizedListData = { Value: string; __name__: string; [index: string]: string };
export interface RawListContainerProps {
tableResult: DataFrame;
}
const styles = {
wrapper: css`
height: 100%;
overflow: scroll;
`,
switchWrapper: css`
display: flex;
flex-direction: row;
margin-bottom: 0;
`,
switchLabel: css`
margin-left: 15px;
margin-bottom: 0;
`,
switch: css`
margin-left: 10px;
`,
resultCount: css`
margin-bottom: 4px;
`,
header: css`
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
font-size: 12px;
line-height: 1.25;
`,
};
const mobileWidthThreshold = 480;
const numberOfColumnsBeforeExpandedViewIsDefault = 2;
/**
* The container that provides the virtualized list to the child components
* @param props
* @constructor
*/
const RawListContainer = (props: RawListContainerProps) => {
const { tableResult } = props;
const dataFrame = cloneDeep(tableResult);
const listRef = useRef<List | null>(null);
const valueLabels = dataFrame.fields.filter((field) => field.name.includes('Value'));
const items = getRawPrometheusListItemsFromDataFrame(dataFrame);
const { width } = useWindowSize();
const [isExpandedView, setIsExpandedView] = useState(
width <= mobileWidthThreshold || valueLabels.length > numberOfColumnsBeforeExpandedViewIsDefault
);
const onContentClick = () => {
setIsExpandedView(!isExpandedView);
};
useEffect(() => {
// After the expanded view has updated, tell the list to re-render
listRef.current?.resetAfterIndex(0, true);
}, [isExpandedView]);
const calculateInitialHeight = (length: number): number => {
const maxListHeight = 600;
const shortListLength = 10;
if (length < shortListLength) {
let sum = 0;
for (let i = 0; i < length; i++) {
sum += getListItemHeight(i, true);
}
return Math.min(maxListHeight, sum);
}
return maxListHeight;
};
const getListItemHeight = (itemIndex: number, isExpandedView: boolean) => {
const singleLineHeight = 32;
const additionalLineHeight = 22;
if (!isExpandedView) {
return singleLineHeight;
}
const item = items[itemIndex];
// Height of 1.5 lines, plus the number of non-value attributes times the height of additional lines
return 1.5 * singleLineHeight + (Object.keys(item).length - valueLabels.length) * additionalLineHeight;
};
return (
<section>
<header className={styles.header}>
<Field className={styles.switchWrapper} label={`Expand results`} htmlFor={'isExpandedView'}>
<div className={styles.switch}>
<Switch onChange={onContentClick} id="isExpandedView" value={isExpandedView} label={`Expand results`} />
</div>
</Field>
<div className={styles.resultCount}>Result series: {items.length}</div>
</header>
<div role={'table'}>
{
<>
{/* Show the value headings above all the values, but only if we're in the contracted view */}
{valueLabels.length > 1 && !isExpandedView && (
<ItemLabels valueLabels={valueLabels} expanded={isExpandedView} />
)}
<List
ref={listRef}
itemCount={items.length}
className={styles.wrapper}
itemSize={(index) => getListItemHeight(index, isExpandedView)}
height={calculateInitialHeight(items.length)}
width="100%"
>
{({ index, style }) => {
let filteredValueLabels: DataFrameField[] | undefined;
if (isExpandedView) {
filteredValueLabels = valueLabels.filter((valueLabel) => {
const itemWithValue = items[index][valueLabel.name];
return itemWithValue && itemWithValue !== RawPrometheusListItemEmptyValue;
});
}
return (
<div role="row" style={{ ...style, overflow: 'hidden' }}>
<RawListItem
isExpandedView={isExpandedView}
valueLabels={filteredValueLabels}
totalNumberOfValues={valueLabels.length}
listKey={items[index].__name__}
listItemData={items[index]}
/>
</div>
);
}}
</List>
</>
}
</div>
</section>
);
};
export default RawListContainer;

View File

@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import RawListItem, { RawListProps } from './RawListItem';
function getCopyElement(): HTMLElement {
return screen.getByLabelText('Copy to clipboard');
}
const defaultProps: RawListProps = {
isExpandedView: false,
listItemData: {
Value: '1234556677888',
__name__: 'metric_name_here',
job: 'jobValue',
instance: 'instanceValue',
},
listKey: '0',
totalNumberOfValues: 1,
};
describe('RawListItem', () => {
it('should render', () => {
render(<RawListItem {...defaultProps} />);
const copyElement = getCopyElement();
expect(copyElement).toBeInTheDocument();
expect(copyElement).toBeVisible();
expect(screen.getAllByText(`jobValue`)[0]).toBeVisible();
expect(screen.getAllByText(`instanceValue`)[0]).toBeVisible();
expect(screen.getAllByText(`metric_name_here`)[0]).toBeVisible();
expect(screen.getAllByText(`1234556677888`)[0]).toBeVisible();
});
});

View File

@ -0,0 +1,162 @@
import { css } from '@emotion/css';
import React from 'react';
import { useCopyToClipboard } from 'react-use';
import { Field, GrafanaTheme2 } from '@grafana/data/';
import { IconButton, useStyles2 } from '@grafana/ui/';
import { ItemLabels } from './ItemLabels';
import { ItemValues } from './ItemValues';
import { instantQueryRawVirtualizedListData } from './RawListContainer';
import RawListItemAttributes from './RawListItemAttributes';
export interface RawListProps {
listItemData: instantQueryRawVirtualizedListData;
listKey: string;
totalNumberOfValues: number;
valueLabels?: Field[];
isExpandedView: boolean;
}
export type RawListValue = { key: string; value: string };
export const rawListExtraSpaceAtEndOfLine = '20px';
export const rawListItemColumnWidth = '80px';
export const rawListPaddingToHoldSpaceForCopyIcon = '25px';
const getStyles = (theme: GrafanaTheme2, totalNumberOfValues: number, isExpandedView: boolean) => ({
rowWrapper: css`
border-bottom: 1px solid ${theme.colors.border.medium};
display: flex;
position: relative;
padding-left: 22px;
${!isExpandedView ? 'align-items: center;' : ''}
${!isExpandedView ? 'height: 100%;' : ''}
`,
copyToClipboardWrapper: css`
position: absolute;
left: 0;
${!isExpandedView ? 'bottom: 0;' : ''}
${isExpandedView ? 'top: 4px;' : 'top: 0;'}
margin: auto;
z-index: 1;
height: 16px;
width: 16px;
`,
rowLabelWrapWrap: css`
position: relative;
width: calc(100% - (${totalNumberOfValues} * ${rawListItemColumnWidth}) - ${rawListPaddingToHoldSpaceForCopyIcon});
`,
rowLabelWrap: css`
white-space: nowrap;
overflow-x: auto;
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
padding-right: ${rawListExtraSpaceAtEndOfLine};
&::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
&:after {
pointer-events: none;
content: '';
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
background: linear-gradient(
to right,
transparent calc(100% - ${rawListExtraSpaceAtEndOfLine}),
${theme.colors.background.primary}
);
}
`,
});
function getQueryValues(allLabels: Pick<instantQueryRawVirtualizedListData, 'Value' | string | number>) {
let attributeValues: RawListValue[] = [];
let values: RawListValue[] = [];
for (const key in allLabels) {
if (key in allLabels && allLabels[key] && !key.includes('Value')) {
attributeValues.push({
key: key,
value: allLabels[key],
});
} else if (key in allLabels && allLabels[key] && key.includes('Value')) {
values.push({
key: key,
value: allLabels[key],
});
}
}
return {
values: values,
attributeValues: attributeValues,
};
}
const RawListItem = ({ listItemData, listKey, totalNumberOfValues, valueLabels, isExpandedView }: RawListProps) => {
const { __name__, ...allLabels } = listItemData;
const [_, copyToClipboard] = useCopyToClipboard();
const displayLength = valueLabels?.length ?? totalNumberOfValues;
const styles = useStyles2((theme) => getStyles(theme, displayLength, isExpandedView));
const { values, attributeValues } = getQueryValues(allLabels);
/**
* Transform the symbols in the dataFrame to uniform strings
*/
const transformCopyValue = (value: string): string => {
if (value === '∞') {
return '+Inf';
}
return value;
};
// Convert the object back into a string
const stringRep = `${__name__}{${attributeValues.map((value) => {
// For histograms the string representation currently in this object is not directly queryable in all situations, leading to broken copied queries. Omitting the attribute from the copied result gives a query which returns all le values, which I assume to be a more common use case.
return value.key !== 'le' ? `${value.key}="${transformCopyValue(value.value)}"` : '';
})}}`;
const hideFieldsWithoutValues = Boolean(valueLabels && valueLabels?.length);
return (
<>
{valueLabels !== undefined && isExpandedView && (
<ItemLabels valueLabels={valueLabels} expanded={isExpandedView} />
)}
<div key={listKey} className={styles.rowWrapper}>
<span className={styles.copyToClipboardWrapper}>
<IconButton tooltip="Copy to clipboard" onClick={() => copyToClipboard(stringRep)} name="copy" />
</span>
<span role={'cell'} className={styles.rowLabelWrapWrap}>
<div className={styles.rowLabelWrap}>
<span>{__name__}</span>
<span>{`{`}</span>
<span>
{attributeValues.map((value, index) => (
<RawListItemAttributes
isExpandedView={isExpandedView}
value={value}
key={index}
index={index}
length={attributeValues.length}
/>
))}
</span>
<span>{`}`}</span>
</div>
</span>
{/* Output the values */}
<ItemValues
hideFieldsWithoutValues={hideFieldsWithoutValues}
totalNumberOfValues={displayLength}
values={values}
/>
</div>
</>
);
};
export default RawListItem;

View File

@ -0,0 +1,48 @@
import { render } from '@testing-library/react';
import React from 'react';
import { RawListValue } from './RawListItem';
import RawListItemAttributes from './RawListItemAttributes';
const getDefaultProps = (
override: Partial<{
value: RawListValue;
index: number;
length: number;
isExpandedView: boolean;
}>
) => {
const key = 'key';
const value = 'value';
return {
value: {
key: key,
value: value,
},
index: 0,
length: 0,
isExpandedView: false,
...override,
};
};
describe('RawListItemAttributes', () => {
it('should render collapsed', () => {
const props = getDefaultProps({ isExpandedView: false });
const attributeRow = render(<RawListItemAttributes {...props} />);
expect(attributeRow.getByText(props.value.value)).toBeVisible();
expect(attributeRow.getByText(props.value.key)).toBeVisible();
expect(attributeRow.baseElement.textContent).toEqual(`${props.value.key}="${props.value.value}"`);
});
it('should render expanded', () => {
const props = getDefaultProps({ isExpandedView: true });
const attributeRow = render(<RawListItemAttributes {...props} />);
expect(attributeRow.getByText(props.value.value)).toBeVisible();
expect(attributeRow.getByText(props.value.key)).toBeVisible();
expect(attributeRow.baseElement.textContent).toEqual(`${props.value.key}="${props.value.value}"`);
});
});

View File

@ -0,0 +1,59 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data/';
import { useStyles2 } from '@grafana/ui/';
import { RawListValue } from './RawListItem';
const getStyles = (theme: GrafanaTheme2) => {
// Borrowed from the monaco styles
const reddish = theme.isDark ? '#ce9178' : '#a31515';
const greenish = theme.isDark ? '#73bf69' : '#56a64b';
return {
metricName: css`
color: ${greenish};
`,
metricValue: css`
color: ${reddish};
`,
expanded: css`
display: block;
text-indent: 1em;
`,
};
};
const RawListItemAttributes = ({
value,
index,
length,
isExpandedView,
}: {
value: RawListValue;
index: number;
length: number;
isExpandedView: boolean;
}) => {
const styles = useStyles2(getStyles);
// From the beginning of the string to the start of the `=`
const attributeName = value.key;
// From after the `="` to before the last `"`
const attributeValue = value.value;
return (
<span className={isExpandedView ? styles.expanded : ''} key={index}>
<span className={styles.metricName}>{attributeName}</span>
<span>=</span>
<span>&quot;</span>
<span className={styles.metricValue}>{attributeValue}</span>
<span>&quot;</span>
{index < length - 1 && <span>, </span>}
</span>
);
};
export default RawListItemAttributes;

View File

@ -0,0 +1,84 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import React from 'react';
import { FieldType, getDefaultTimeRange, InternalTimeZones, toDataFrame } from '@grafana/data';
import { ExploreId, TABLE_RESULTS_STYLE } from 'app/types/explore';
import { RawPrometheusContainer } from './RawPrometheusContainer';
function getTable(): HTMLElement {
return screen.getAllByRole('table')[0];
}
function getTableToggle(): HTMLElement {
return screen.getAllByRole('radio')[0];
}
function getRowsData(rows: HTMLElement[]): Object[] {
let content = [];
for (let i = 1; i < rows.length; i++) {
content.push({
time: within(rows[i]).getByText(/2021*/).textContent,
text: within(rows[i]).getByText(/test_string_*/).textContent,
});
}
return content;
}
const dataFrame = toDataFrame({
name: 'A',
fields: [
{
name: 'time',
type: FieldType.time,
values: [1609459200000, 1609470000000, 1609462800000, 1609466400000],
config: {
custom: {
filterable: false,
},
},
},
{
name: 'text',
type: FieldType.string,
values: ['test_string_1', 'test_string_2', 'test_string_3', 'test_string_4'],
config: {
custom: {
filterable: false,
},
},
},
],
});
const defaultProps = {
exploreId: ExploreId.left,
loading: false,
width: 800,
onCellFilterAdded: jest.fn(),
tableResult: [dataFrame],
splitOpenFn: () => {},
range: getDefaultTimeRange(),
timeZone: InternalTimeZones.utc,
resultsStyle: TABLE_RESULTS_STYLE.raw,
showRawPrometheus: false,
};
describe('RawPrometheusContainer', () => {
it('should render component for prometheus', () => {
render(<RawPrometheusContainer {...defaultProps} showRawPrometheus={true} />);
expect(screen.queryAllByRole('table').length).toBe(1);
fireEvent.click(getTableToggle());
expect(getTable()).toBeInTheDocument();
const rows = within(getTable()).getAllByRole('row');
expect(rows).toHaveLength(5);
expect(getRowsData(rows)).toEqual([
{ time: '2021-01-01 00:00:00', text: 'test_string_1' },
{ time: '2021-01-01 03:00:00', text: 'test_string_2' },
{ time: '2021-01-01 01:00:00', text: 'test_string_3' },
{ time: '2021-01-01 02:00:00', text: 'test_string_4' },
]);
});
});

View File

@ -0,0 +1,168 @@
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen, TimeZone, ValueLinkConfig } from '@grafana/data';
import { Collapse, RadioButtonGroup, Table } from '@grafana/ui';
import { FilterItem } from '@grafana/ui/src/components/Table/types';
import { config } from 'app/core/config';
import { PANEL_BORDER } from 'app/core/constants';
import { StoreState, TABLE_RESULTS_STYLE } from 'app/types';
import { ExploreId, ExploreItemState, TABLE_RESULTS_STYLES, TableResultsStyle } from 'app/types/explore';
import { MetaInfoText } from './MetaInfoText';
import RawListContainer from './PrometheusListView/RawListContainer';
import { getFieldLinksForExplore } from './utils/links';
interface RawPrometheusContainerProps {
ariaLabel?: string;
exploreId: ExploreId;
width: number;
timeZone: TimeZone;
onCellFilterAdded?: (filter: FilterItem) => void;
showRawPrometheus?: boolean;
splitOpenFn: SplitOpen;
}
interface PrometheusContainerState {
resultsStyle: TableResultsStyle;
}
function mapStateToProps(state: StoreState, { exploreId }: RawPrometheusContainerProps) {
const explore = state.explore;
const item: ExploreItemState = explore[exploreId]!;
const { loading: loadingInState, tableResult, rawPrometheusResult, range } = item;
const rawPrometheusFrame: DataFrame[] = rawPrometheusResult ? [rawPrometheusResult] : [];
const result = (tableResult?.length ?? false) > 0 && rawPrometheusResult ? tableResult : rawPrometheusFrame;
const loading = result && result.length > 0 ? false : loadingInState;
return { loading, tableResult: result, range };
}
const connector = connect(mapStateToProps, {});
type Props = RawPrometheusContainerProps & ConnectedProps<typeof connector>;
export class RawPrometheusContainer extends PureComponent<Props, PrometheusContainerState> {
constructor(props: Props) {
super(props);
// If resultsStyle is undefined we won't render the toggle, and the default table will be rendered
if (props.showRawPrometheus) {
this.state = {
resultsStyle: TABLE_RESULTS_STYLE.raw,
};
}
}
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) {
return 200;
}
// tries to estimate table height
return Math.max(Math.min(600, mainFrame.length * 35) + 35);
}
renderLabel = () => {
const spacing = css({
display: 'flex',
justifyContent: 'space-between',
});
const ALL_GRAPH_STYLE_OPTIONS: Array<SelectableValue<TableResultsStyle>> = TABLE_RESULTS_STYLES.map((style) => ({
value: style,
// capital-case it and switch `_` to ` `
label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '),
}));
return (
<div className={spacing}>
{this.state.resultsStyle === TABLE_RESULTS_STYLE.raw ? 'Raw' : 'Table'}
<RadioButtonGroup
size="sm"
options={ALL_GRAPH_STYLE_OPTIONS}
value={this.state?.resultsStyle}
onChange={this.onChangeResultsStyle}
/>
</div>
);
};
render() {
const { loading, onCellFilterAdded, tableResult, width, splitOpenFn, range, ariaLabel, timeZone } = this.props;
const height = this.getTableHeight();
const tableWidth = width - config.theme.panelPadding * 2 - PANEL_BORDER;
let dataFrames = tableResult;
if (dataFrames?.length) {
dataFrames = applyFieldOverrides({
data: dataFrames,
timeZone,
theme: config.theme2,
replaceVariables: (v: string) => v,
fieldConfig: {
defaults: {},
overrides: [],
},
});
// Bit of code smell here. We need to add links here to the frame modifying the frame on every render.
// Should work fine in essence but still not the ideal way to pass props. In logs container we do this
// differently and sidestep this getLinks API on a dataframe
for (const frame of dataFrames) {
for (const field of frame.fields) {
field.getLinks = (config: ValueLinkConfig) => {
return getFieldLinksForExplore({
field,
rowIndex: config.valueRowIndex!,
splitOpenFn,
range,
dataFrame: frame!,
});
};
}
}
}
const mainFrame = this.getMainFrame(dataFrames);
const subFrames = dataFrames?.filter((df) => df.meta?.custom?.parentRowIndex !== undefined);
const label = this.state?.resultsStyle !== undefined ? this.renderLabel() : 'Table';
// Render table as default if resultsStyle is not set.
const renderTable = !this.state?.resultsStyle || this.state?.resultsStyle === TABLE_RESULTS_STYLE.table;
return (
<Collapse label={label} loading={loading} isOpen>
{mainFrame?.length && (
<>
{renderTable && (
<Table
ariaLabel={ariaLabel}
data={mainFrame}
subData={subFrames}
width={tableWidth}
height={height}
onCellFilterAdded={onCellFilterAdded}
/>
)}
{this.state?.resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={mainFrame} />}
</>
)}
{!mainFrame?.length && <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
</Collapse>
);
}
}
export default connector(RawPrometheusContainer);

View File

@ -56,10 +56,12 @@ function setup(error: DataQueryError) {
tableFrames: [],
traceFrames: [],
nodeGraphFrames: [],
rawPrometheusFrames: [],
flameGraphFrames: [],
graphResult: null,
logsResult: null,
tableResult: null,
rawPrometheusResult: null,
};
render(
<Provider store={store}>

View File

@ -1,7 +1,7 @@
import { screen, render, within } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import React from 'react';
import { DataFrame, toDataFrame, FieldType, InternalTimeZones } from '@grafana/data';
import { DataFrame, FieldType, getDefaultTimeRange, InternalTimeZones, toDataFrame } from '@grafana/data';
import { ExploreId } from 'app/types/explore';
import { TableContainer } from './TableContainer';
@ -54,7 +54,7 @@ const defaultProps = {
onCellFilterAdded: jest.fn(),
tableResult: [dataFrame],
splitOpenFn: () => {},
range: {} as any,
range: getDefaultTimeRange(),
timeZone: InternalTimeZones.utc,
};

View File

@ -922,9 +922,11 @@ export const processQueryResponse = (
graphResult,
logsResult,
tableResult,
rawPrometheusResult,
traceFrames,
nodeGraphFrames,
flameGraphFrames,
rawPrometheusFrames,
} = response;
if (error) {
@ -961,6 +963,7 @@ export const processQueryResponse = (
queryResponse: response,
graphResult,
tableResult,
rawPrometheusResult,
logsResult,
loading: loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming,
showLogs: !!logsResult,
@ -968,6 +971,7 @@ export const processQueryResponse = (
showTable: !!tableResult?.length,
showTrace: !!traceFrames.length,
showNodeGraph: !!nodeGraphFrames.length,
showRawPrometheus: !!rawPrometheusFrames.length,
showFlameGraph: !!flameGraphFrames.length,
};
};

View File

@ -73,6 +73,7 @@ export const makeExplorePaneState = (): ExploreItemState => ({
tableResult: null,
graphResult: null,
logsResult: null,
rawPrometheusResult: null,
eventBridge: null as unknown as EventBusExtended,
cache: [],
richHistory: [],
@ -92,6 +93,8 @@ export const createEmptyQueryResponse = (): ExplorePanelData => ({
nodeGraphFrames: [],
flameGraphFrames: [],
tableFrames: [],
rawPrometheusFrames: [],
rawPrometheusResult: null,
graphResult: null,
logsResult: null,
tableResult: null,

View File

@ -97,6 +97,8 @@ const createExplorePanelData = (args: Partial<ExplorePanelData>): ExplorePanelDa
traceFrames: [],
nodeGraphFrames: [],
flameGraphFrames: [],
rawPrometheusFrames: [],
rawPrometheusResult: null,
};
return { ...defaults, ...args };
@ -128,6 +130,8 @@ describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => {
graphResult: null,
tableResult: null,
logsResult: null,
rawPrometheusFrames: [],
rawPrometheusResult: null,
});
});
@ -153,6 +157,8 @@ describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => {
graphResult: null,
tableResult: null,
logsResult: null,
rawPrometheusFrames: [],
rawPrometheusResult: null,
});
});
@ -181,6 +187,8 @@ describe('decorateWithGraphLogsTraceTableAndFlameGraph', () => {
graphResult: null,
tableResult: null,
logsResult: null,
rawPrometheusFrames: [],
rawPrometheusResult: null,
});
});
});

View File

@ -29,6 +29,7 @@ import { preProcessPanelData } from '../../query/state/runRequest';
export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData => {
const graphFrames: DataFrame[] = [];
const tableFrames: DataFrame[] = [];
const rawPrometheusFrames: DataFrame[] = [];
const logsFrames: DataFrame[] = [];
const traceFrames: DataFrame[] = [];
const nodeGraphFrames: DataFrame[] = [];
@ -48,6 +49,9 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData
case 'table':
tableFrames.push(frame);
break;
case 'rawPrometheus':
rawPrometheusFrames.push(frame);
break;
case 'nodeGraph':
nodeGraphFrames.push(frame);
break;
@ -73,9 +77,11 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData
traceFrames,
nodeGraphFrames,
flameGraphFrames,
rawPrometheusFrames,
graphResult: null,
tableResult: null,
logsResult: null,
rawPrometheusResult: null,
};
};
@ -105,7 +111,7 @@ export const decorateWithGraphResult = (data: ExplorePanelData): ExplorePanelDat
/**
* This processing returns Observable because it uses Transformer internally which result type is also Observable.
* In this case the transformer should return single result but it is possible that in the future it could return
* In this case the transformer should return single result, but it is possible that in the future it could return
* multiple results and so this should be used with mergeMap or similar to unbox the internal observable.
*/
export const decorateWithTableResult = (data: ExplorePanelData): Observable<ExplorePanelData> => {
@ -155,6 +161,56 @@ export const decorateWithTableResult = (data: ExplorePanelData): Observable<Expl
);
};
export const decorateWithRawPrometheusResult = (data: ExplorePanelData): Observable<ExplorePanelData> => {
// Prometheus has a custom frame visualization alongside the table view, but they both handle the data the same
const tableFrames = data.rawPrometheusFrames;
if (!tableFrames || tableFrames.length === 0) {
return of({ ...data, tableResult: null });
}
tableFrames.sort((frameA: DataFrame, frameB: DataFrame) => {
const frameARefId = frameA.refId!;
const frameBRefId = frameB.refId!;
if (frameARefId > frameBRefId) {
return 1;
}
if (frameARefId < frameBRefId) {
return -1;
}
return 0;
});
const hasOnlyTimeseries = tableFrames.every((df) => isTimeSeries(df));
// If we have only timeseries we do join on default time column which makes more sense. If we are showing
// non timeseries or some mix of data we are not trying to join on anything and just try to merge them in
// single table, which may not make sense in most cases, but it's up to the user to query something sensible.
const transformer = hasOnlyTimeseries
? of(tableFrames).pipe(standardTransformers.joinByFieldTransformer.operator({}))
: of(tableFrames).pipe(standardTransformers.mergeTransformer.operator({}));
return transformer.pipe(
map((frames) => {
const frame = frames[0];
// set display processor
for (const field of frame.fields) {
field.display =
field.display ??
getDisplayProcessor({
field,
theme: config.theme2,
timeZone: data.request?.timezone ?? 'browser',
});
}
return { ...data, rawPrometheusResult: frame };
})
);
};
export const decorateWithLogsResult =
(
options: {
@ -195,7 +251,9 @@ export function decorateData(
map(decorateWithCorrelations({ queries, correlations })),
map(decorateWithFrameTypeMetadata),
map(decorateWithGraphResult),
map(decorateWithGraphResult),
map(decorateWithLogsResult({ absoluteRange, refreshInterval, queries, fullRangeLogsVolumeAvailable })),
mergeMap(decorateWithRawPrometheusResult),
mergeMap(decorateWithTableResult)
);
}

View File

@ -0,0 +1,36 @@
import { DataFrame, FieldType, FormattedValue, toDataFrame } from '@grafana/data/src';
import { getRawPrometheusListItemsFromDataFrame } from './getRawPrometheusListItemsFromDataFrame';
describe('getRawPrometheusListItemsFromDataFrame', () => {
it('Parses empty dataframe', () => {
const dataFrame: DataFrame = { fields: [], length: 0 };
const result = getRawPrometheusListItemsFromDataFrame(dataFrame);
expect(result).toEqual([]);
});
it('Parses mock dataframe', () => {
const display = (value: string, decimals?: number): FormattedValue => {
return { text: value };
};
const dataFrame = toDataFrame({
name: 'A',
fields: [
{ display, name: 'Time', type: FieldType.time, values: [3000, 4000, 5000, 6000, 7000, 8000] },
{
display,
name: '__name__',
type: FieldType.string,
values: ['ALERTS', 'ALERTS', 'ALERTS', 'ALERTS_FOR_STATE', 'ALERTS_FOR_STATE', 'ALERTS_FOR_STATE'],
},
{ display, name: 'Value', type: FieldType.number, values: [1, 2, 3, 4, 5, 6] },
{ display, name: 'attribute', type: FieldType.number, values: [7, 8, 9, 10, 11, 12] },
],
});
const result = getRawPrometheusListItemsFromDataFrame(dataFrame);
const differenceBetweenValueAndAttribute = 6;
result.forEach((row) => {
expect(parseInt(row.attribute, 10)).toEqual(parseInt(row.Value, 10) + differenceBetweenValueAndAttribute);
});
});
});

View File

@ -0,0 +1,60 @@
import { DataFrame, formattedValueToString } from '@grafana/data/src';
import { instantQueryRawVirtualizedListData } from '../PrometheusListView/RawListContainer';
type instantQueryMetricList = { [index: string]: { [index: string]: instantQueryRawVirtualizedListData } };
export const RawPrometheusListItemEmptyValue = ' ';
/**
* transform dataFrame to instantQueryRawVirtualizedListData
* @param dataFrame
*/
export const getRawPrometheusListItemsFromDataFrame = (dataFrame: DataFrame): instantQueryRawVirtualizedListData[] => {
const metricList: instantQueryMetricList = {};
const outputList: instantQueryRawVirtualizedListData[] = [];
// Filter out time
const newFields = dataFrame.fields.filter((field) => !['Time'].includes(field.name));
// Get name from each series
let metricNames: string[] = newFields.find((field) => field.name === '__name__')?.values.toArray() ?? [];
if (!metricNames.length && newFields.length && newFields[0].values.length) {
// These results do not have series labels
// Matching the native prometheus UI which appears to only show the permutations of the first field in the query result.
metricNames = Array(newFields[0].values.length).fill('');
}
// Get everything that isn't the name from each series
const metricLabels = dataFrame.fields.filter((field) => !['__name__'].includes(field.name));
metricNames.forEach(function (metric: string, i: number) {
metricList[metric] = {};
const formattedMetric: instantQueryRawVirtualizedListData = metricList[metric][i] ?? {};
for (const field of metricLabels) {
const label = field.name;
if (label !== 'Time') {
// Initialize the objects
if (typeof field?.display === 'function') {
const stringValue = formattedValueToString(field?.display(field.values.get(i)));
if (stringValue) {
formattedMetric[label] = stringValue;
} else if (label.includes('Value #')) {
formattedMetric[label] = RawPrometheusListItemEmptyValue;
}
} else {
console.warn('Field display method is missing!');
}
}
}
outputList.push({
...formattedMetric,
__name__: metric,
});
});
return outputList;
};

View File

@ -1,4 +1,11 @@
import { DataFrame, DataQueryRequest, DataQueryResponse, FieldType, MutableDataFrame } from '@grafana/data';
import {
DataFrame,
DataQueryRequest,
DataQueryResponse,
FieldType,
MutableDataFrame,
PreferredVisualisationType,
} from '@grafana/data';
import { parseSampleValue, transform, transformDFToTable, transformV2 } from './result_transformer';
import { PromQuery } from './types';
@ -131,7 +138,7 @@ describe('Prometheus Result Transformer', () => {
expect(series.data[0].fields[1].name).toEqual('label1');
expect(series.data[0].fields[2].name).toEqual('label2');
expect(series.data[0].fields[3].name).toEqual('Value');
expect(series.data[0].meta?.preferredVisualisationType).toEqual('table');
expect(series.data[0].meta?.preferredVisualisationType).toEqual('rawPrometheus');
});
it('results with table format and multiple data frames should be transformed to 1 table dataFrame', () => {
@ -181,7 +188,7 @@ describe('Prometheus Result Transformer', () => {
expect(series.data[0].fields[3].name).toEqual('label3');
expect(series.data[0].fields[4].name).toEqual('label4');
expect(series.data[0].fields[5].name).toEqual('Value');
expect(series.data[0].meta?.preferredVisualisationType).toEqual('table');
expect(series.data[0].meta?.preferredVisualisationType).toEqual('rawPrometheus' as PreferredVisualisationType);
});
it('results with table and time_series format should be correctly transformed', () => {
@ -230,7 +237,7 @@ describe('Prometheus Result Transformer', () => {
expect(series.data[0].fields.length).toEqual(2);
expect(series.data[0].meta?.preferredVisualisationType).toEqual('graph');
expect(series.data[1].fields.length).toEqual(4);
expect(series.data[1].meta?.preferredVisualisationType).toEqual('table');
expect(series.data[1].meta?.preferredVisualisationType).toEqual('rawPrometheus' as PreferredVisualisationType);
});
it('results with heatmap format should be correctly transformed', () => {

View File

@ -228,7 +228,8 @@ export function transformDFToTable(dfs: DataFrame[]): DataFrame[] {
return {
refId,
fields,
meta: { ...dfs[0].meta, preferredVisualisationType: 'table' as PreferredVisualisationType },
// Prometheus specific UI for instant queries
meta: { ...dfs[0].meta, preferredVisualisationType: 'rawPrometheus' as PreferredVisualisationType },
length: timeField.values.length,
};
});
@ -263,7 +264,7 @@ export function transform(
valueWithRefId: transformOptions.target.valueWithRefId,
meta: {
// Fix for showing of Prometheus results in Explore table
preferredVisualisationType: transformOptions.query.instant ? 'table' : 'graph',
preferredVisualisationType: transformOptions.query.instant ? 'rawPrometheus' : 'graph',
},
};
const prometheusResult = response.data.data;

View File

@ -150,6 +150,11 @@ export interface ExploreItemState {
*/
tableResult: DataFrame[] | null;
/**
* Simple UI that emulates native prometheus UI
*/
rawPrometheusResult: DataFrame | null;
/**
* React keys for rendering of QueryRows
*/
@ -177,6 +182,10 @@ export interface ExploreItemState {
showLogs?: boolean;
showMetrics?: boolean;
showTable?: boolean;
/**
* If true, the default "raw" prometheus instant query UI will be displayed in addition to table view
*/
showRawPrometheus?: boolean;
showTrace?: boolean;
showNodeGraph?: boolean;
showFlameGraph?: boolean;
@ -247,8 +256,17 @@ export interface ExplorePanelData extends PanelData {
logsFrames: DataFrame[];
traceFrames: DataFrame[];
nodeGraphFrames: DataFrame[];
rawPrometheusFrames: DataFrame[];
flameGraphFrames: DataFrame[];
graphResult: DataFrame[] | null;
tableResult: DataFrame[] | null;
logsResult: LogsModel | null;
rawPrometheusResult: DataFrame | null;
}
export enum TABLE_RESULTS_STYLE {
table = 'table',
raw = 'raw',
}
export const TABLE_RESULTS_STYLES = [TABLE_RESULTS_STYLE.table, TABLE_RESULTS_STYLE.raw];
export type TableResultsStyle = typeof TABLE_RESULTS_STYLES[number];