mirror of https://github.com/grafana/grafana
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>pull/60918/head^2
parent
0e7640475f
commit
0e265245eb
@ -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(); |
||||
}); |
||||
}); |
@ -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; |
@ -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' }, |
||||
]); |
||||
}); |
||||
}); |
@ -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); |
@ -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; |
||||
}; |
Loading…
Reference in new issue