diff --git a/.betterer.results b/.betterer.results index 0f9d9944c52..4027c16c076 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"] ], diff --git a/packages/grafana-data/src/types/data.ts b/packages/grafana-data/src/types/data.ts index d1f2e4614e1..3063eaa7224 100644 --- a/packages/grafana-data/src/types/data.ts +++ b/packages/grafana-data/src/types/data.ts @@ -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]; /** diff --git a/public/app/features/explore/Explore.test.tsx b/public/app/features/explore/Explore.test.tsx index 38ffabdbff8..baf7bcec799 100644 --- a/public/app/features/explore/Explore.test.tsx +++ b/public/app/features/explore/Explore.test.tsx @@ -86,6 +86,7 @@ const dummyProps: Props = { splitted: false, isFromCompactUrl: false, eventBus: new EventBusSrv(), + showRawPrometheus: false, }; jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 128266630d5..83373d562b4 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -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 { ); } + renderRawPrometheus(width: number) { + const { exploreId, datasourceInstance, timeZone } = this.props; + return ( + + ); + } + 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 { theme, showMetrics, showTable, + showRawPrometheus, showLogs, showTrace, showNodeGraph, @@ -410,6 +427,7 @@ export class Explore extends React.PureComponent { 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 { {showMetrics && graphResult && ( {this.renderGraphPanel(width)} )} + {showRawPrometheus && ( + {this.renderRawPrometheus(width)} + )} {showTable && {this.renderTablePanel(width)}} {showLogs && {this.renderLogsPanel(width)}} {showNodeGraph && {this.renderNodeGraphPanel()}} @@ -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, diff --git a/public/app/features/explore/ExploreQueryInspector.test.tsx b/public/app/features/explore/ExploreQueryInspector.test.tsx index bb1861607b9..096a797d903 100644 --- a/public/app/features/explore/ExploreQueryInspector.test.tsx +++ b/public/app/features/explore/ExploreQueryInspector.test.tsx @@ -51,9 +51,11 @@ const setup = (propOverrides = {}) => { traceFrames: [], nodeGraphFrames: [], flameGraphFrames: [], + rawPrometheusFrames: [], graphResult: null, logsResult: null, tableResult: null, + rawPrometheusResult: null, }, runQueries: jest.fn(), ...propOverrides, diff --git a/public/app/features/explore/PrometheusListView/ItemLabels.tsx b/public/app/features/explore/PrometheusListView/ItemLabels.tsx new file mode 100644 index 00000000000..40fb7ba9712 --- /dev/null +++ b/public/app/features/explore/PrometheusListView/ItemLabels.tsx @@ -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 ( +
+
+ {valueLabels.map((value, index) => ( + + {value.name} + + ))} +
+
+ ); +}; diff --git a/public/app/features/explore/PrometheusListView/ItemValues.test.tsx b/public/app/features/explore/PrometheusListView/ItemValues.test.tsx new file mode 100644 index 00000000000..7a5a1e965d2 --- /dev/null +++ b/public/app/features/explore/PrometheusListView/ItemValues.test.tsx @@ -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(); + 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(); + expect(screen.getByText(value1)).toBeVisible(); + expect(screen.getByText(value2)).toBeVisible(); + expect(itemValues?.baseElement?.children?.item(0)?.children?.item(0)?.children.length).toBe(2); + }); +}); diff --git a/public/app/features/explore/PrometheusListView/ItemValues.tsx b/public/app/features/explore/PrometheusListView/ItemValues.tsx new file mode 100644 index 00000000000..5d7d37a7f21 --- /dev/null +++ b/public/app/features/explore/PrometheusListView/ItemValues.tsx @@ -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 ( +
+ {values?.map((value) => { + if (hideFieldsWithoutValues && (value.value === undefined || value.value === RawPrometheusListItemEmptyValue)) { + return null; + } + + return ( + + {value.value} + + ); + })} +
+ ); +}; diff --git a/public/app/features/explore/PrometheusListView/RawListContainer.test.tsx b/public/app/features/explore/PrometheusListView/RawListContainer.test.tsx new file mode 100644 index 00000000000..8e7d9583f00 --- /dev/null +++ b/public/app/features/explore/PrometheusListView/RawListContainer.test.tsx @@ -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(); + + 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(); + }); + }); +}); diff --git a/public/app/features/explore/PrometheusListView/RawListContainer.tsx b/public/app/features/explore/PrometheusListView/RawListContainer.tsx new file mode 100644 index 00000000000..ecd9bb4f582 --- /dev/null +++ b/public/app/features/explore/PrometheusListView/RawListContainer.tsx @@ -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(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 ( +
+
+ +
+ +
+
+ +
Result series: {items.length}
+
+ +
+ { + <> + {/* Show the value headings above all the values, but only if we're in the contracted view */} + {valueLabels.length > 1 && !isExpandedView && ( + + )} + 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 ( +
+ +
+ ); + }} +
+ + } +
+
+ ); +}; + +export default RawListContainer; diff --git a/public/app/features/explore/PrometheusListView/RawListItem.test.tsx b/public/app/features/explore/PrometheusListView/RawListItem.test.tsx new file mode 100644 index 00000000000..ab92ad6ddca --- /dev/null +++ b/public/app/features/explore/PrometheusListView/RawListItem.test.tsx @@ -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(); + + 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(); + }); +}); diff --git a/public/app/features/explore/PrometheusListView/RawListItem.tsx b/public/app/features/explore/PrometheusListView/RawListItem.tsx new file mode 100644 index 00000000000..15e6b082be8 --- /dev/null +++ b/public/app/features/explore/PrometheusListView/RawListItem.tsx @@ -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) { + 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 && ( + + )} +
+ + copyToClipboard(stringRep)} name="copy" /> + + +
+ {__name__} + {`{`} + + {attributeValues.map((value, index) => ( + + ))} + + {`}`} +
+
+ + {/* Output the values */} + +
+ + ); +}; +export default RawListItem; diff --git a/public/app/features/explore/PrometheusListView/RawListItemAttributes.test.tsx b/public/app/features/explore/PrometheusListView/RawListItemAttributes.test.tsx new file mode 100644 index 00000000000..27c3ab604d8 --- /dev/null +++ b/public/app/features/explore/PrometheusListView/RawListItemAttributes.test.tsx @@ -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(); + + 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(); + + 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}"`); + }); +}); diff --git a/public/app/features/explore/PrometheusListView/RawListItemAttributes.tsx b/public/app/features/explore/PrometheusListView/RawListItemAttributes.tsx new file mode 100644 index 00000000000..fb3996a8636 --- /dev/null +++ b/public/app/features/explore/PrometheusListView/RawListItemAttributes.tsx @@ -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 ( + + {attributeName} + = + " + {attributeValue} + " + {index < length - 1 && , } + + ); +}; + +export default RawListItemAttributes; diff --git a/public/app/features/explore/RawPrometheusContainer.test.tsx b/public/app/features/explore/RawPrometheusContainer.test.tsx new file mode 100644 index 00000000000..2f750d7f752 --- /dev/null +++ b/public/app/features/explore/RawPrometheusContainer.test.tsx @@ -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(); + + 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' }, + ]); + }); +}); diff --git a/public/app/features/explore/RawPrometheusContainer.tsx b/public/app/features/explore/RawPrometheusContainer.tsx new file mode 100644 index 00000000000..27cc9721c1b --- /dev/null +++ b/public/app/features/explore/RawPrometheusContainer.tsx @@ -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; + +export class RawPrometheusContainer extends PureComponent { + 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> = TABLE_RESULTS_STYLES.map((style) => ({ + value: style, + // capital-case it and switch `_` to ` ` + label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '), + })); + + return ( +
+ {this.state.resultsStyle === TABLE_RESULTS_STYLE.raw ? 'Raw' : 'Table'} + +
+ ); + }; + + 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 ( + + {mainFrame?.length && ( + <> + {renderTable && ( + + )} + {this.state?.resultsStyle === TABLE_RESULTS_STYLE.raw && } + + )} + {!mainFrame?.length && } + + ); + } +} + +export default connector(RawPrometheusContainer); diff --git a/public/app/features/explore/ResponseErrorContainer.test.tsx b/public/app/features/explore/ResponseErrorContainer.test.tsx index 395a65512a0..dec33a6bf35 100644 --- a/public/app/features/explore/ResponseErrorContainer.test.tsx +++ b/public/app/features/explore/ResponseErrorContainer.test.tsx @@ -56,10 +56,12 @@ function setup(error: DataQueryError) { tableFrames: [], traceFrames: [], nodeGraphFrames: [], + rawPrometheusFrames: [], flameGraphFrames: [], graphResult: null, logsResult: null, tableResult: null, + rawPrometheusResult: null, }; render( diff --git a/public/app/features/explore/TableContainer.test.tsx b/public/app/features/explore/TableContainer.test.tsx index b877134aad6..7515d93906a 100644 --- a/public/app/features/explore/TableContainer.test.tsx +++ b/public/app/features/explore/TableContainer.test.tsx @@ -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, }; diff --git a/public/app/features/explore/state/query.ts b/public/app/features/explore/state/query.ts index 1ce8de80f06..ddff617d572 100644 --- a/public/app/features/explore/state/query.ts +++ b/public/app/features/explore/state/query.ts @@ -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, }; }; diff --git a/public/app/features/explore/state/utils.ts b/public/app/features/explore/state/utils.ts index f980cadd52e..5d31ad3c5d0 100644 --- a/public/app/features/explore/state/utils.ts +++ b/public/app/features/explore/state/utils.ts @@ -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, diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index 8e819dd1b7c..a3cc75e3798 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -97,6 +97,8 @@ const createExplorePanelData = (args: Partial): 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, }); }); }); diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index 9ab44c8270b..9417f582875 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -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 => { @@ -155,6 +161,56 @@ export const decorateWithTableResult = (data: ExplorePanelData): Observable => { + // 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) ); } diff --git a/public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.test.ts b/public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.test.ts new file mode 100644 index 00000000000..3b475b2ca87 --- /dev/null +++ b/public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.test.ts @@ -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); + }); + }); +}); diff --git a/public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.ts b/public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.ts new file mode 100644 index 00000000000..bf1a9794368 --- /dev/null +++ b/public/app/features/explore/utils/getRawPrometheusListItemsFromDataFrame.ts @@ -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; +}; diff --git a/public/app/plugins/datasource/prometheus/result_transformer.test.ts b/public/app/plugins/datasource/prometheus/result_transformer.test.ts index cd87c69e6f7..4a05f7dff37 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.test.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.test.ts @@ -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', () => { diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index 9e4159b3b6e..6dcd4d9bf3d 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -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; diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index efc9852c4e8..28b966563ac 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -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];