mirror of https://github.com/grafana/grafana
Prometheus: Metrics browser (#33847)
* [WIP] Metrics browser * Removed unused import * Metrics selection logic * Remove redundant tests All data is fetched now regardless to the current range so test for checking reloading the data on the range change are no longer relevant. * Remove commented out code blocks * Add issue number to todos Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>pull/33689/head^2
parent
8d442c9b44
commit
4b0b69292e
@ -0,0 +1,123 @@ |
||||
import React, { forwardRef, HTMLAttributes } from 'react'; |
||||
import { cx, css } from '@emotion/css'; |
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useTheme2 } from '@grafana/ui'; |
||||
// @ts-ignore
|
||||
import Highlighter from 'react-highlight-words'; |
||||
|
||||
/** |
||||
* @public |
||||
*/ |
||||
export type OnLabelClick = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => void; |
||||
|
||||
export interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onClick'> { |
||||
name: string; |
||||
active?: boolean; |
||||
loading?: boolean; |
||||
searchTerm?: string; |
||||
value?: string; |
||||
facets?: number; |
||||
onClick?: OnLabelClick; |
||||
} |
||||
|
||||
/** |
||||
* TODO #33976: Create a common, shared component with public/app/plugins/datasource/loki/components/LokiLabel.tsx |
||||
*/ |
||||
export const Label = forwardRef<HTMLElement, Props>( |
||||
({ name, value, hidden, facets, onClick, className, loading, searchTerm, active, style, ...rest }, ref) => { |
||||
const theme = useTheme2(); |
||||
const styles = getLabelStyles(theme); |
||||
const searchWords = searchTerm ? [searchTerm] : []; |
||||
|
||||
const onLabelClick = (event: React.MouseEvent<HTMLElement>) => { |
||||
if (onClick && !hidden) { |
||||
onClick(name, value, event); |
||||
} |
||||
}; |
||||
// Using this component for labels and label values. If value is given use value for display text.
|
||||
let text = value || name; |
||||
if (facets) { |
||||
text = `${text} (${facets})`; |
||||
} |
||||
|
||||
return ( |
||||
<span |
||||
key={text} |
||||
ref={ref} |
||||
onClick={onLabelClick} |
||||
style={style} |
||||
title={text} |
||||
role="option" |
||||
aria-selected={!!active} |
||||
className={cx( |
||||
styles.base, |
||||
active && styles.active, |
||||
loading && styles.loading, |
||||
hidden && styles.hidden, |
||||
className, |
||||
onClick && !hidden && styles.hover |
||||
)} |
||||
{...rest} |
||||
> |
||||
<Highlighter textToHighlight={text} searchWords={searchWords} highlightClassName={styles.matchHighLight} /> |
||||
</span> |
||||
); |
||||
} |
||||
); |
||||
|
||||
Label.displayName = 'Label'; |
||||
|
||||
const getLabelStyles = (theme: GrafanaTheme2) => ({ |
||||
base: css` |
||||
cursor: pointer; |
||||
font-size: ${theme.typography.size.sm}; |
||||
line-height: ${theme.typography.bodySmall.lineHeight}; |
||||
background-color: ${theme.colors.background.secondary}; |
||||
vertical-align: baseline; |
||||
color: ${theme.colors.text}; |
||||
white-space: nowrap; |
||||
text-shadow: none; |
||||
padding: ${theme.spacing(0.5)}; |
||||
border-radius: ${theme.shape.borderRadius()}; |
||||
margin-right: ${theme.spacing(1)}; |
||||
margin-bottom: ${theme.spacing(0.5)}; |
||||
`,
|
||||
loading: css` |
||||
font-weight: ${theme.typography.fontWeightMedium}; |
||||
background-color: ${theme.colors.primary.shade}; |
||||
color: ${theme.colors.text.primary}; |
||||
animation: pulse 3s ease-out 0s infinite normal forwards; |
||||
@keyframes pulse { |
||||
0% { |
||||
color: ${theme.colors.text.primary}; |
||||
} |
||||
50% { |
||||
color: ${theme.colors.text.secondary}; |
||||
} |
||||
100% { |
||||
color: ${theme.colors.text.disabled}; |
||||
} |
||||
} |
||||
`,
|
||||
active: css` |
||||
font-weight: ${theme.typography.fontWeightMedium}; |
||||
background-color: ${theme.colors.primary.main}; |
||||
color: ${theme.colors.primary.contrastText}; |
||||
`,
|
||||
matchHighLight: css` |
||||
background: inherit; |
||||
color: ${theme.colors.primary.text}; |
||||
background-color: ${theme.colors.primary.transparent}; |
||||
`,
|
||||
hidden: css` |
||||
opacity: 0.6; |
||||
cursor: default; |
||||
border: 1px solid transparent; |
||||
`,
|
||||
hover: css` |
||||
&:hover { |
||||
opacity: 0.85; |
||||
cursor: pointer; |
||||
} |
||||
`,
|
||||
}); |
@ -0,0 +1,265 @@ |
||||
import React from 'react'; |
||||
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
|
||||
import { getTheme } from '@grafana/ui'; |
||||
import { |
||||
buildSelector, |
||||
facetLabels, |
||||
SelectableLabel, |
||||
UnthemedPrometheusMetricsBrowser, |
||||
BrowserProps, |
||||
} from './PrometheusMetricsBrowser'; |
||||
import PromQlLanguageProvider from '../language_provider'; |
||||
|
||||
describe('buildSelector()', () => { |
||||
it('returns an empty selector for no labels', () => { |
||||
expect(buildSelector([])).toEqual('{}'); |
||||
}); |
||||
it('returns an empty selector for selected labels with no values', () => { |
||||
const labels: SelectableLabel[] = [{ name: 'foo', selected: true }]; |
||||
expect(buildSelector(labels)).toEqual('{}'); |
||||
}); |
||||
it('returns an empty selector for one selected label with no selected values', () => { |
||||
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar' }] }]; |
||||
expect(buildSelector(labels)).toEqual('{}'); |
||||
}); |
||||
it('returns a simple selector from a selected label with a selected value', () => { |
||||
const labels: SelectableLabel[] = [{ name: 'foo', selected: true, values: [{ name: 'bar', selected: true }] }]; |
||||
expect(buildSelector(labels)).toEqual('{foo="bar"}'); |
||||
}); |
||||
it('metric selector without labels', () => { |
||||
const labels: SelectableLabel[] = [{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }]; |
||||
expect(buildSelector(labels)).toEqual('foo{}'); |
||||
}); |
||||
it('selector with multiple metrics', () => { |
||||
const labels: SelectableLabel[] = [ |
||||
{ |
||||
name: '__name__', |
||||
selected: true, |
||||
values: [ |
||||
{ name: 'foo', selected: true }, |
||||
{ name: 'bar', selected: true }, |
||||
], |
||||
}, |
||||
]; |
||||
expect(buildSelector(labels)).toEqual('{__name__=~"foo|bar"}'); |
||||
}); |
||||
it('metric selector with labels', () => { |
||||
const labels: SelectableLabel[] = [ |
||||
{ name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }, |
||||
{ name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] }, |
||||
]; |
||||
expect(buildSelector(labels)).toEqual('foo{bar="baz"}'); |
||||
}); |
||||
}); |
||||
|
||||
describe('facetLabels()', () => { |
||||
const possibleLabels = { |
||||
cluster: ['dev'], |
||||
namespace: ['alertmanager'], |
||||
}; |
||||
const labels: SelectableLabel[] = [ |
||||
{ name: 'foo', selected: true, values: [{ name: 'bar' }] }, |
||||
{ name: 'cluster', values: [{ name: 'dev' }, { name: 'ops' }, { name: 'prod' }] }, |
||||
{ name: 'namespace', values: [{ name: 'alertmanager' }] }, |
||||
]; |
||||
|
||||
it('returns no labels given an empty label set', () => { |
||||
expect(facetLabels([], {})).toEqual([]); |
||||
}); |
||||
|
||||
it('marks all labels as hidden when no labels are possible', () => { |
||||
const result = facetLabels(labels, {}); |
||||
expect(result.length).toEqual(labels.length); |
||||
expect(result[0].hidden).toBeTruthy(); |
||||
expect(result[0].values).toBeUndefined(); |
||||
}); |
||||
|
||||
it('keeps values as facetted when they are possible', () => { |
||||
const result = facetLabels(labels, possibleLabels); |
||||
expect(result.length).toEqual(labels.length); |
||||
expect(result[0].hidden).toBeTruthy(); |
||||
expect(result[0].values).toBeUndefined(); |
||||
expect(result[1].hidden).toBeFalsy(); |
||||
expect(result[1].values!.length).toBe(1); |
||||
expect(result[1].values![0].name).toBe('dev'); |
||||
}); |
||||
|
||||
it('does not facet out label values that are currently being facetted', () => { |
||||
const result = facetLabels(labels, possibleLabels, 'cluster'); |
||||
expect(result.length).toEqual(labels.length); |
||||
expect(result[0].hidden).toBeTruthy(); |
||||
expect(result[1].hidden).toBeFalsy(); |
||||
// 'cluster' is being facetted, should show all 3 options even though only 1 is possible
|
||||
expect(result[1].values!.length).toBe(3); |
||||
expect(result[2].values!.length).toBe(1); |
||||
}); |
||||
}); |
||||
|
||||
describe('PrometheusMetricsBrowser', () => { |
||||
const setupProps = (): BrowserProps => { |
||||
const mockLanguageProvider = { |
||||
start: () => Promise.resolve(), |
||||
getLabelValues: (name: string) => { |
||||
switch (name) { |
||||
case 'label1': |
||||
return ['value1-1', 'value1-2']; |
||||
case 'label2': |
||||
return ['value2-1', 'value2-2']; |
||||
case 'label3': |
||||
return ['value3-1', 'value3-2']; |
||||
} |
||||
return []; |
||||
}, |
||||
fetchSeriesLabels: (selector: string) => { |
||||
switch (selector) { |
||||
case '{label1="value1-1"}': |
||||
return { label1: ['value1-1'], label2: ['value2-1'], label3: ['value3-1'] }; |
||||
case '{label1=~"value1-1|value1-2"}': |
||||
return { label1: ['value1-1', 'value1-2'], label2: ['value2-1'], label3: ['value3-1', 'value3-2'] }; |
||||
} |
||||
// Allow full set by default
|
||||
return { |
||||
label1: ['value1-1', 'value1-2'], |
||||
label2: ['value2-1', 'value2-2'], |
||||
}; |
||||
}, |
||||
getLabelKeys: () => ['label1', 'label2', 'label3'], |
||||
}; |
||||
|
||||
const defaults: BrowserProps = { |
||||
theme: getTheme(), |
||||
onChange: () => {}, |
||||
autoSelect: 0, |
||||
languageProvider: (mockLanguageProvider as unknown) as PromQlLanguageProvider, |
||||
}; |
||||
|
||||
return defaults; |
||||
}; |
||||
|
||||
// Clear label selection manually because it's saved in localStorage
|
||||
afterEach(() => { |
||||
const clearBtn = screen.getByLabelText('Selector clear button'); |
||||
userEvent.click(clearBtn); |
||||
}); |
||||
|
||||
it('renders and loader shows when empty, and then first set of labels', async () => { |
||||
const props = setupProps(); |
||||
render(<UnthemedPrometheusMetricsBrowser {...props} />); |
||||
// Loading appears and dissappears
|
||||
screen.getByText(/Loading labels/); |
||||
await waitFor(() => { |
||||
expect(screen.queryByText(/Loading labels/)).not.toBeInTheDocument(); |
||||
}); |
||||
// Initial set of labels is available and not selected
|
||||
expect(screen.queryByRole('option', { name: 'label1' })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('option', { name: 'label1', selected: true })).not.toBeInTheDocument(); |
||||
expect(screen.queryByRole('option', { name: 'label2' })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('option', { name: 'label2', selected: true })).not.toBeInTheDocument(); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); |
||||
}); |
||||
|
||||
it('allows label and value selection/deselection', async () => { |
||||
const props = setupProps(); |
||||
render(<UnthemedPrometheusMetricsBrowser {...props} />); |
||||
// Selecting label2
|
||||
const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); |
||||
expect(screen.queryByRole('list', { name: /Values/ })).not.toBeInTheDocument(); |
||||
userEvent.click(label2); |
||||
expect(screen.queryByRole('option', { name: /label2/, selected: true })).toBeInTheDocument(); |
||||
// List of values for label2 appears
|
||||
expect(await screen.findAllByRole('list')).toHaveLength(1); |
||||
expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2'); |
||||
expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument(); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); |
||||
// Selecting label1, list for its values appears
|
||||
const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); |
||||
userEvent.click(label1); |
||||
expect(screen.queryByRole('option', { name: /label1/, selected: true })).toBeInTheDocument(); |
||||
await screen.findByLabelText('Values for label1'); |
||||
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2); |
||||
// Selecting value2-2 of label2
|
||||
const value = await screen.findByRole('option', { name: 'value2-2', selected: false }); |
||||
userEvent.click(value); |
||||
await screen.findByRole('option', { name: 'value2-2', selected: true }); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-2"}'); |
||||
// Selecting value2-1 of label2, both values now selected
|
||||
const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false }); |
||||
userEvent.click(value2); |
||||
// await screen.findByRole('option', {name: 'value2-1', selected: true});
|
||||
await screen.findByText('{label2=~"value2-1|value2-2"}'); |
||||
// Deselecting value2-2, one value should remain
|
||||
const selectedValue = await screen.findByRole('option', { name: 'value2-2', selected: true }); |
||||
userEvent.click(selectedValue); |
||||
await screen.findByRole('option', { name: 'value2-1', selected: true }); |
||||
await screen.findByRole('option', { name: 'value2-2', selected: false }); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}'); |
||||
// Selecting value from label1 for combined selector
|
||||
const value1 = await screen.findByRole('option', { name: 'value1-2', selected: false }); |
||||
userEvent.click(value1); |
||||
await screen.findByRole('option', { name: 'value1-2', selected: true }); |
||||
await screen.findByText('{label1="value1-2",label2="value2-1"}'); |
||||
// Deselect label1 should remove label and value
|
||||
const selectedLabel = (await screen.findAllByRole('option', { name: /label1/, selected: true }))[0]; |
||||
userEvent.click(selectedLabel); |
||||
await screen.findByRole('option', { name: /label1/, selected: false }); |
||||
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(1); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}'); |
||||
// Clear selector
|
||||
const clearBtn = screen.getByLabelText('Selector clear button'); |
||||
userEvent.click(clearBtn); |
||||
await screen.findByRole('option', { name: /label2/, selected: false }); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}'); |
||||
}); |
||||
|
||||
it('filters values by input text', async () => { |
||||
const props = setupProps(); |
||||
render(<UnthemedPrometheusMetricsBrowser {...props} />); |
||||
// Selecting label2 and label1
|
||||
const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); |
||||
userEvent.click(label2); |
||||
const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); |
||||
userEvent.click(label1); |
||||
await screen.findByLabelText('Values for label1'); |
||||
await screen.findByLabelText('Values for label2'); |
||||
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); |
||||
// Typing '1' to filter for values
|
||||
userEvent.type(screen.getByLabelText('Filter expression for label values'), '1'); |
||||
expect(screen.getByLabelText('Filter expression for label values')).toHaveValue('1'); |
||||
expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument(); |
||||
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(3); |
||||
}); |
||||
|
||||
it('facets labels', async () => { |
||||
const props = setupProps(); |
||||
render(<UnthemedPrometheusMetricsBrowser {...props} />); |
||||
// Selecting label2 and label1
|
||||
const label2 = await screen.findByRole('option', { name: /label2/, selected: false }); |
||||
userEvent.click(label2); |
||||
const label1 = await screen.findByRole('option', { name: /label1/, selected: false }); |
||||
userEvent.click(label1); |
||||
await screen.findByLabelText('Values for label1'); |
||||
await screen.findByLabelText('Values for label2'); |
||||
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4); |
||||
expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3'); |
||||
// Click value1-1 which triggers facetting for value3-x, and still show all value1-x
|
||||
const value1 = await screen.findByRole('option', { name: 'value1-1', selected: false }); |
||||
userEvent.click(value1); |
||||
await waitForElementToBeRemoved(screen.queryByRole('option', { name: 'value2-2' })); |
||||
expect(screen.queryByRole('option', { name: 'value1-2' })).toBeInTheDocument(); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1="value1-1"}'); |
||||
expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3 (1)'); |
||||
// Click value1-2 for which facetting will allow more values for value3-x
|
||||
const value12 = await screen.findByRole('option', { name: 'value1-2', selected: false }); |
||||
userEvent.click(value12); |
||||
await screen.findByRole('option', { name: 'value1-2', selected: true }); |
||||
userEvent.click(screen.getByRole('option', { name: /label3/ })); |
||||
await screen.findByLabelText('Values for label3'); |
||||
expect(screen.queryByRole('option', { name: 'value1-1', selected: true })).toBeInTheDocument(); |
||||
expect(screen.queryByRole('option', { name: 'value1-2', selected: true })).toBeInTheDocument(); |
||||
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1=~"value1-1|value1-2"}'); |
||||
expect(screen.queryAllByRole('option', { name: /label3/ })[0]).toHaveTextContent('label3 (2)'); |
||||
}); |
||||
}); |
@ -0,0 +1,633 @@ |
||||
import React, { ChangeEvent } from 'react'; |
||||
import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, stylesFactory, withTheme } from '@grafana/ui'; |
||||
import PromQlLanguageProvider from '../language_provider'; |
||||
import { css, cx } from '@emotion/css'; |
||||
import store from 'app/core/store'; |
||||
import { FixedSizeList } from 'react-window'; |
||||
|
||||
import { GrafanaTheme } from '@grafana/data'; |
||||
import { Label as PromLabel } from './Label'; |
||||
|
||||
// Hard limit on labels to render
|
||||
const MAX_LABEL_COUNT = 10000; |
||||
const MAX_VALUE_COUNT = 10000; |
||||
const EMPTY_SELECTOR = '{}'; |
||||
const METRIC_LABEL = '__name__'; |
||||
export const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels'; |
||||
|
||||
export interface BrowserProps { |
||||
languageProvider: PromQlLanguageProvider; |
||||
onChange: (selector: string) => void; |
||||
theme: GrafanaTheme; |
||||
autoSelect?: number; |
||||
hide?: () => void; |
||||
} |
||||
|
||||
interface BrowserState { |
||||
labels: SelectableLabel[]; |
||||
labelSearchTerm: string; |
||||
metricSearchTerm: string; |
||||
status: string; |
||||
error: string; |
||||
validationStatus: string; |
||||
valueSearchTerm: string; |
||||
} |
||||
|
||||
interface FacettableValue { |
||||
name: string; |
||||
selected?: boolean; |
||||
} |
||||
|
||||
export interface SelectableLabel { |
||||
name: string; |
||||
selected?: boolean; |
||||
loading?: boolean; |
||||
values?: FacettableValue[]; |
||||
hidden?: boolean; |
||||
facets?: number; |
||||
} |
||||
|
||||
export function buildSelector(labels: SelectableLabel[]): string { |
||||
let singleMetric = ''; |
||||
const selectedLabels = []; |
||||
for (const label of labels) { |
||||
if ((label.name === METRIC_LABEL || label.selected) && label.values && label.values.length > 0) { |
||||
const selectedValues = label.values.filter((value) => value.selected).map((value) => value.name); |
||||
if (selectedValues.length > 1) { |
||||
selectedLabels.push(`${label.name}=~"${selectedValues.join('|')}"`); |
||||
} else if (selectedValues.length === 1) { |
||||
if (label.name === METRIC_LABEL) { |
||||
singleMetric = selectedValues[0]; |
||||
} else { |
||||
selectedLabels.push(`${label.name}="${selectedValues[0]}"`); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
return [singleMetric, '{', selectedLabels.join(','), '}'].join(''); |
||||
} |
||||
|
||||
export function facetLabels( |
||||
labels: SelectableLabel[], |
||||
possibleLabels: Record<string, string[]>, |
||||
lastFacetted?: string |
||||
): SelectableLabel[] { |
||||
return labels.map((label) => { |
||||
const possibleValues = possibleLabels[label.name]; |
||||
if (possibleValues) { |
||||
let existingValues: FacettableValue[]; |
||||
if (label.name === lastFacetted && label.values) { |
||||
// Facetting this label, show all values
|
||||
existingValues = label.values; |
||||
} else { |
||||
// Keep selection in other facets
|
||||
const selectedValues: Set<string> = new Set( |
||||
label.values?.filter((value) => value.selected).map((value) => value.name) || [] |
||||
); |
||||
// Values for this label have not been requested yet, let's use the facetted ones as the initial values
|
||||
existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) })); |
||||
} |
||||
return { |
||||
...label, |
||||
loading: false, |
||||
values: existingValues, |
||||
hidden: !possibleValues, |
||||
facets: existingValues.length, |
||||
}; |
||||
} |
||||
|
||||
// Label is facetted out, hide all values
|
||||
return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 }; |
||||
}); |
||||
} |
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({ |
||||
wrapper: css` |
||||
background-color: ${theme.colors.bg2}; |
||||
padding: ${theme.spacing.md}; |
||||
width: 100%; |
||||
`,
|
||||
list: css` |
||||
margin-top: ${theme.spacing.sm}; |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
max-height: 200px; |
||||
overflow: auto; |
||||
`,
|
||||
section: css` |
||||
& + & { |
||||
margin: ${theme.spacing.md} 0; |
||||
} |
||||
position: relative; |
||||
`,
|
||||
selector: css` |
||||
font-family: ${theme.typography.fontFamily.monospace}; |
||||
margin-bottom: ${theme.spacing.sm}; |
||||
`,
|
||||
status: css` |
||||
padding: ${theme.spacing.xs}; |
||||
color: ${theme.colors.textSemiWeak}; |
||||
white-space: nowrap; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
/* using absolute positioning because flex interferes with ellipsis */ |
||||
position: absolute; |
||||
width: 50%; |
||||
right: 0; |
||||
text-align: right; |
||||
transition: opacity 100ms linear; |
||||
opacity: 0; |
||||
`,
|
||||
statusShowing: css` |
||||
opacity: 1; |
||||
`,
|
||||
error: css` |
||||
color: ${theme.palette.brandDanger}; |
||||
`,
|
||||
valueList: css` |
||||
margin-right: ${theme.spacing.sm}; |
||||
`,
|
||||
valueListWrapper: css` |
||||
border-left: 1px solid ${theme.colors.border2}; |
||||
margin: ${theme.spacing.sm} 0; |
||||
padding: ${theme.spacing.sm} 0 ${theme.spacing.sm} ${theme.spacing.sm}; |
||||
`,
|
||||
valueListArea: css` |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
margin-top: ${theme.spacing.sm}; |
||||
`,
|
||||
valueTitle: css` |
||||
margin-left: -${theme.spacing.xs}; |
||||
margin-bottom: ${theme.spacing.sm}; |
||||
`,
|
||||
validationStatus: css` |
||||
padding: ${theme.spacing.xs}; |
||||
margin-bottom: ${theme.spacing.sm}; |
||||
color: ${theme.colors.textStrong}; |
||||
white-space: nowrap; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
`,
|
||||
})); |
||||
|
||||
/** |
||||
* TODO #33976: Remove duplicated code. The component is very similar to LokiLabelBrowser.tsx. Check if it's possible |
||||
* to create a single, generic component. |
||||
*/ |
||||
export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserProps, BrowserState> { |
||||
state = { |
||||
labels: [] as SelectableLabel[], |
||||
labelSearchTerm: '', |
||||
metricSearchTerm: '', |
||||
status: 'Ready', |
||||
error: '', |
||||
validationStatus: '', |
||||
valueSearchTerm: '', |
||||
}; |
||||
|
||||
onChangeLabelSearch = (event: ChangeEvent<HTMLInputElement>) => { |
||||
this.setState({ labelSearchTerm: event.target.value }); |
||||
}; |
||||
|
||||
onChangeMetricSearch = (event: ChangeEvent<HTMLInputElement>) => { |
||||
this.setState({ metricSearchTerm: event.target.value }); |
||||
}; |
||||
|
||||
onChangeValueSearch = (event: ChangeEvent<HTMLInputElement>) => { |
||||
this.setState({ valueSearchTerm: event.target.value }); |
||||
}; |
||||
|
||||
onClickRunQuery = () => { |
||||
const selector = buildSelector(this.state.labels); |
||||
this.props.onChange(selector); |
||||
}; |
||||
|
||||
onClickRunRateQuery = () => { |
||||
const selector = buildSelector(this.state.labels); |
||||
const query = `rate(${selector}[$__interval])`; |
||||
this.props.onChange(query); |
||||
}; |
||||
|
||||
onClickClear = () => { |
||||
this.setState((state) => { |
||||
const labels: SelectableLabel[] = state.labels.map((label) => ({ |
||||
...label, |
||||
values: undefined, |
||||
selected: false, |
||||
loading: false, |
||||
hidden: false, |
||||
facets: undefined, |
||||
})); |
||||
return { |
||||
labels, |
||||
labelSearchTerm: '', |
||||
metricSearchTerm: '', |
||||
status: '', |
||||
error: '', |
||||
validationStatus: '', |
||||
valueSearchTerm: '', |
||||
}; |
||||
}); |
||||
store.delete(LAST_USED_LABELS_KEY); |
||||
// Get metrics
|
||||
this.fetchValues(METRIC_LABEL); |
||||
}; |
||||
|
||||
onClickLabel = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => { |
||||
const label = this.state.labels.find((l) => l.name === name); |
||||
if (!label) { |
||||
return; |
||||
} |
||||
// Toggle selected state
|
||||
const selected = !label.selected; |
||||
let nextValue: Partial<SelectableLabel> = { selected }; |
||||
if (label.values && !selected) { |
||||
// Deselect all values if label was deselected
|
||||
const values = label.values.map((value) => ({ ...value, selected: false })); |
||||
nextValue = { ...nextValue, facets: 0, values }; |
||||
} |
||||
// Resetting search to prevent empty results
|
||||
this.setState({ labelSearchTerm: '' }); |
||||
this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name)); |
||||
}; |
||||
|
||||
onClickValue = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => { |
||||
const label = this.state.labels.find((l) => l.name === name); |
||||
if (!label || !label.values) { |
||||
return; |
||||
} |
||||
// Resetting search to prevent empty results
|
||||
this.setState({ labelSearchTerm: '' }); |
||||
// Toggling value for selected label, leaving other values intact
|
||||
const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected })); |
||||
this.updateLabelState(name, { values }, '', () => this.doFacetting(name)); |
||||
}; |
||||
|
||||
onClickMetric = (name: string, value: string | undefined, event: React.MouseEvent<HTMLElement>) => { |
||||
// Finding special metric label
|
||||
const label = this.state.labels.find((l) => l.name === name); |
||||
if (!label || !label.values) { |
||||
return; |
||||
} |
||||
// Resetting search to prevent empty results
|
||||
this.setState({ metricSearchTerm: '' }); |
||||
// Toggling value for selected label, leaving other values intact
|
||||
const values = label.values.map((v) => ({ |
||||
...v, |
||||
selected: v.name === value || v.selected ? !v.selected : v.selected, |
||||
})); |
||||
// Toggle selected state of special metrics label
|
||||
const selected = values.some((v) => v.selected); |
||||
this.updateLabelState(name, { selected, values }, '', () => this.doFacetting(name)); |
||||
}; |
||||
|
||||
onClickValidate = () => { |
||||
const selector = buildSelector(this.state.labels); |
||||
this.validateSelector(selector); |
||||
}; |
||||
|
||||
updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) { |
||||
this.setState((state) => { |
||||
const labels: SelectableLabel[] = state.labels.map((label) => { |
||||
if (label.name === name) { |
||||
return { ...label, ...updatedFields }; |
||||
} |
||||
return label; |
||||
}); |
||||
// New status overrides errors
|
||||
const error = status ? '' : state.error; |
||||
return { labels, status, error, validationStatus: '' }; |
||||
}, cb); |
||||
} |
||||
|
||||
componentDidMount() { |
||||
const { languageProvider } = this.props; |
||||
if (languageProvider) { |
||||
const selectedLabels: string[] = store.getObject(LAST_USED_LABELS_KEY, []); |
||||
languageProvider.start().then(() => { |
||||
let rawLabels: string[] = languageProvider.getLabelKeys(); |
||||
// TODO too-many-metrics
|
||||
if (rawLabels.length > MAX_LABEL_COUNT) { |
||||
const error = `Too many labels found (showing only ${MAX_LABEL_COUNT} of ${rawLabels.length})`; |
||||
rawLabels = rawLabels.slice(0, MAX_LABEL_COUNT); |
||||
this.setState({ error }); |
||||
} |
||||
// Get metrics
|
||||
this.fetchValues(METRIC_LABEL); |
||||
// Auto-select previously selected labels
|
||||
const labels: SelectableLabel[] = rawLabels.map((label, i, arr) => ({ |
||||
name: label, |
||||
selected: selectedLabels.includes(label), |
||||
loading: false, |
||||
})); |
||||
// Pre-fetch values for selected labels
|
||||
this.setState({ labels }, () => { |
||||
this.state.labels.forEach((label) => { |
||||
if (label.selected) { |
||||
this.fetchValues(label.name); |
||||
} |
||||
}); |
||||
}); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
doFacettingForLabel(name: string) { |
||||
const label = this.state.labels.find((l) => l.name === name); |
||||
if (!label) { |
||||
return; |
||||
} |
||||
const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name); |
||||
store.setObject(LAST_USED_LABELS_KEY, selectedLabels); |
||||
if (label.selected) { |
||||
// Refetch values for newly selected label...
|
||||
if (!label.values) { |
||||
this.fetchValues(name); |
||||
} |
||||
} else { |
||||
// Only need to facet when deselecting labels
|
||||
this.doFacetting(); |
||||
} |
||||
} |
||||
|
||||
doFacetting = (lastFacetted?: string) => { |
||||
const selector = buildSelector(this.state.labels); |
||||
if (selector === EMPTY_SELECTOR) { |
||||
// Clear up facetting
|
||||
const labels: SelectableLabel[] = this.state.labels.map((label) => { |
||||
return { ...label, facets: 0, values: undefined, hidden: false }; |
||||
}); |
||||
this.setState({ labels }, () => { |
||||
// Get fresh set of values
|
||||
this.state.labels.forEach( |
||||
(label) => (label.selected || label.name === METRIC_LABEL) && this.fetchValues(label.name) |
||||
); |
||||
}); |
||||
} else { |
||||
// Do facetting
|
||||
this.fetchSeries(selector, lastFacetted); |
||||
} |
||||
}; |
||||
|
||||
async fetchValues(name: string) { |
||||
const { languageProvider } = this.props; |
||||
this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`); |
||||
try { |
||||
let rawValues = await languageProvider.getLabelValues(name); |
||||
if (rawValues.length > MAX_VALUE_COUNT) { |
||||
const error = `Too many values for ${name} (showing only ${MAX_VALUE_COUNT} of ${rawValues.length})`; |
||||
rawValues = rawValues.slice(0, MAX_VALUE_COUNT); |
||||
this.setState({ error }); |
||||
} |
||||
const values: FacettableValue[] = rawValues.map((value) => ({ name: value })); |
||||
this.updateLabelState(name, { values, loading: false }, ''); |
||||
} catch (error) { |
||||
console.error(error); |
||||
} |
||||
} |
||||
|
||||
async fetchSeries(selector: string, lastFacetted?: string) { |
||||
const { languageProvider } = this.props; |
||||
if (lastFacetted) { |
||||
this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`); |
||||
} |
||||
try { |
||||
const possibleLabels = await languageProvider.fetchSeriesLabels(selector, true); |
||||
if (Object.keys(possibleLabels).length === 0) { |
||||
// Sometimes the backend does not return a valid set
|
||||
console.error('No results for label combination, but should not occur.'); |
||||
this.setState({ error: `Facetting failed for ${selector}` }); |
||||
return; |
||||
} |
||||
const labels: SelectableLabel[] = facetLabels(this.state.labels, possibleLabels, lastFacetted); |
||||
this.setState({ labels, error: '' }); |
||||
if (lastFacetted) { |
||||
this.updateLabelState(lastFacetted, { loading: false }); |
||||
} |
||||
} catch (error) { |
||||
console.error(error); |
||||
} |
||||
} |
||||
|
||||
async validateSelector(selector: string) { |
||||
const { languageProvider } = this.props; |
||||
this.setState({ validationStatus: `Validating selector ${selector}`, error: '' }); |
||||
const streams = await languageProvider.fetchSeries(selector); |
||||
this.setState({ validationStatus: `Selector is valid (${streams.length} streams found)` }); |
||||
} |
||||
|
||||
render() { |
||||
const { theme } = this.props; |
||||
const { labels, labelSearchTerm, metricSearchTerm, status, error, validationStatus, valueSearchTerm } = this.state; |
||||
const styles = getStyles(theme); |
||||
if (labels.length === 0) { |
||||
return ( |
||||
<div className={styles.wrapper}> |
||||
<LoadingPlaceholder text="Loading labels..." /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
// Filter metrics
|
||||
let metrics = labels.find((label) => label.name === METRIC_LABEL); |
||||
if (metrics && metricSearchTerm) { |
||||
// TODO extract from render() and debounce
|
||||
metrics = { |
||||
...metrics, |
||||
values: metrics.values?.filter((value) => value.selected || value.name.includes(metricSearchTerm)), |
||||
}; |
||||
} |
||||
|
||||
// Filter labels
|
||||
let nonMetricLabels = labels.filter((label) => !label.hidden && label.name !== METRIC_LABEL); |
||||
if (labelSearchTerm) { |
||||
// TODO extract from render() and debounce
|
||||
nonMetricLabels = nonMetricLabels.filter((label) => label.selected || label.name.includes(labelSearchTerm)); |
||||
} |
||||
|
||||
// Filter non-metric label values
|
||||
let selectedLabels = nonMetricLabels.filter((label) => label.selected && label.values); |
||||
if (valueSearchTerm) { |
||||
// TODO extract from render() and debounce
|
||||
selectedLabels = selectedLabels.map((label) => ({ |
||||
...label, |
||||
values: label.values?.filter((value) => value.selected || value.name.includes(valueSearchTerm)), |
||||
})); |
||||
} |
||||
const selector = buildSelector(this.state.labels); |
||||
const empty = selector === EMPTY_SELECTOR; |
||||
return ( |
||||
<div className={styles.wrapper}> |
||||
<HorizontalGroup align="flex-start" spacing="lg"> |
||||
<div> |
||||
<div className={styles.section}> |
||||
<Label description="Which metric do you want to use?">1. Select metric to search in</Label> |
||||
<div> |
||||
<Input |
||||
onChange={this.onChangeMetricSearch} |
||||
aria-label="Filter expression for metric" |
||||
value={metricSearchTerm} |
||||
/> |
||||
</div> |
||||
<div role="list" className={styles.valueListWrapper}> |
||||
<FixedSizeList |
||||
height={550} |
||||
itemCount={metrics?.values?.length || 0} |
||||
itemSize={25} |
||||
itemKey={(i) => (metrics!.values as FacettableValue[])[i].name} |
||||
width={300} |
||||
className={styles.valueList} |
||||
> |
||||
{({ index, style }) => { |
||||
const value = metrics?.values?.[index]; |
||||
if (!value) { |
||||
return null; |
||||
} |
||||
return ( |
||||
<div style={style}> |
||||
<PromLabel |
||||
name={metrics!.name} |
||||
value={value?.name} |
||||
active={value?.selected} |
||||
onClick={this.onClickMetric} |
||||
searchTerm={metricSearchTerm} |
||||
/> |
||||
</div> |
||||
); |
||||
}} |
||||
</FixedSizeList> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div> |
||||
<div className={styles.section}> |
||||
<Label description="Which labels would you like to consider for your search?"> |
||||
2. Select labels to search in |
||||
</Label> |
||||
<div> |
||||
<Input |
||||
onChange={this.onChangeLabelSearch} |
||||
aria-label="Filter expression for label" |
||||
value={labelSearchTerm} |
||||
/> |
||||
</div> |
||||
<div className={styles.list}> |
||||
{nonMetricLabels.map((label) => ( |
||||
<PromLabel |
||||
key={label.name} |
||||
name={label.name} |
||||
loading={label.loading} |
||||
active={label.selected} |
||||
hidden={label.hidden} |
||||
facets={label.facets} |
||||
onClick={this.onClickLabel} |
||||
searchTerm={labelSearchTerm} |
||||
/> |
||||
))} |
||||
</div> |
||||
</div> |
||||
<div className={styles.section}> |
||||
<Label description="Choose the label values that you would like to use for the query. Use the search field to find values across selected labels."> |
||||
3. Find values for the selected labels |
||||
</Label> |
||||
<div> |
||||
<Input |
||||
onChange={this.onChangeValueSearch} |
||||
aria-label="Filter expression for label values" |
||||
value={valueSearchTerm} |
||||
/> |
||||
</div> |
||||
<div className={styles.valueListArea}> |
||||
{selectedLabels.map((label) => ( |
||||
<div |
||||
role="list" |
||||
key={label.name} |
||||
aria-label={`Values for ${label.name}`} |
||||
className={styles.valueListWrapper} |
||||
> |
||||
<div className={styles.valueTitle}> |
||||
<PromLabel |
||||
name={label.name} |
||||
loading={label.loading} |
||||
active={label.selected} |
||||
hidden={label.hidden} |
||||
//If no facets, we want to show number of all label values
|
||||
facets={label.facets || label.values?.length} |
||||
onClick={this.onClickLabel} |
||||
/> |
||||
</div> |
||||
<FixedSizeList |
||||
height={200} |
||||
itemCount={label.values?.length || 0} |
||||
itemSize={25} |
||||
itemKey={(i) => (label.values as FacettableValue[])[i].name} |
||||
width={200} |
||||
className={styles.valueList} |
||||
> |
||||
{({ index, style }) => { |
||||
const value = label.values?.[index]; |
||||
if (!value) { |
||||
return null; |
||||
} |
||||
return ( |
||||
<div style={style}> |
||||
<PromLabel |
||||
name={label.name} |
||||
value={value?.name} |
||||
active={value?.selected} |
||||
onClick={this.onClickValue} |
||||
searchTerm={valueSearchTerm} |
||||
/> |
||||
</div> |
||||
); |
||||
}} |
||||
</FixedSizeList> |
||||
</div> |
||||
))} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</HorizontalGroup> |
||||
|
||||
<div className={styles.section}> |
||||
<Label>4. Resulting selector</Label> |
||||
<div aria-label="selector" className={styles.selector}> |
||||
{selector} |
||||
</div> |
||||
{validationStatus && <div className={styles.validationStatus}>{validationStatus}</div>} |
||||
<HorizontalGroup> |
||||
<Button aria-label="Use selector for query button" disabled={empty} onClick={this.onClickRunQuery}> |
||||
Run query |
||||
</Button> |
||||
<Button |
||||
aria-label="Use selector as metrics button" |
||||
variant="secondary" |
||||
disabled={empty} |
||||
onClick={this.onClickRunRateQuery} |
||||
> |
||||
Run rate query |
||||
</Button> |
||||
<Button |
||||
aria-label="Validate submit button" |
||||
variant="secondary" |
||||
disabled={empty} |
||||
onClick={this.onClickValidate} |
||||
> |
||||
Validate selector |
||||
</Button> |
||||
<Button aria-label="Selector clear button" variant="secondary" onClick={this.onClickClear}> |
||||
Clear |
||||
</Button> |
||||
<div className={cx(styles.status, (status || error) && styles.statusShowing)}> |
||||
<span className={error ? styles.error : ''}>{error || status}</span> |
||||
</div> |
||||
</HorizontalGroup> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export const PrometheusMetricsBrowser = withTheme(UnthemedPrometheusMetricsBrowser); |
Loading…
Reference in new issue