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