mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
0e7640475f
commit
0e265245eb
@ -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"]
|
||||
],
|
||||
|
@ -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];
|
||||
|
||||
/**
|
||||
|
@ -86,6 +86,7 @@ const dummyProps: Props = {
|
||||
splitted: false,
|
||||
isFromCompactUrl: false,
|
||||
eventBus: new EventBusSrv(),
|
||||
showRawPrometheus: false,
|
||||
};
|
||||
|
||||
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
|
||||
|
@ -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,
|
||||
|
@ -51,9 +51,11 @@ const setup = (propOverrides = {}) => {
|
||||
traceFrames: [],
|
||||
nodeGraphFrames: [],
|
||||
flameGraphFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
graphResult: null,
|
||||
logsResult: null,
|
||||
tableResult: null,
|
||||
rawPrometheusResult: null,
|
||||
},
|
||||
runQueries: jest.fn(),
|
||||
...propOverrides,
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
@ -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();
|
||||
});
|
||||
});
|
162
public/app/features/explore/PrometheusListView/RawListItem.tsx
Normal file
162
public/app/features/explore/PrometheusListView/RawListItem.tsx
Normal 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;
|
@ -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}"`);
|
||||
});
|
||||
});
|
@ -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>"</span>
|
||||
<span className={styles.metricValue}>{attributeValue}</span>
|
||||
<span>"</span>
|
||||
{index < length - 1 && <span>, </span>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default RawListItemAttributes;
|
84
public/app/features/explore/RawPrometheusContainer.test.tsx
Normal file
84
public/app/features/explore/RawPrometheusContainer.test.tsx
Normal 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' },
|
||||
]);
|
||||
});
|
||||
});
|
168
public/app/features/explore/RawPrometheusContainer.tsx
Normal file
168
public/app/features/explore/RawPrometheusContainer.tsx
Normal 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);
|
@ -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}>
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -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', () => {
|
||||
|
@ -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;
|
||||
|
@ -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];
|
||||
|
Loading…
Reference in New Issue
Block a user