mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
123
public/app/plugins/datasource/prometheus/components/Label.tsx
Normal file
123
public/app/plugins/datasource/prometheus/components/Label.tsx
Normal file
@@ -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;
|
||||
}
|
||||
`,
|
||||
});
|
||||
@@ -10,6 +10,8 @@ const setup = (renderMethod: any, propOverrides?: object) => {
|
||||
const datasourceMock: unknown = {
|
||||
languageProvider: {
|
||||
syntax: () => {},
|
||||
getLabelKeys: () => [],
|
||||
metrics: [],
|
||||
},
|
||||
};
|
||||
const datasource: PrometheusDatasource = datasourceMock as PrometheusDatasource;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import RCCascader from 'rc-cascader';
|
||||
import React from 'react';
|
||||
import PromQlLanguageProvider from '../language_provider';
|
||||
import PromQueryField, { groupMetricsByPrefix, RECORDING_RULES_GROUP } from './PromQueryField';
|
||||
import { DataSourceInstanceSettings, dateTime } from '@grafana/data';
|
||||
import PromQueryField from './PromQueryField';
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { PromOptions } from '../types';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
describe('PromQueryField', () => {
|
||||
beforeAll(() => {
|
||||
@@ -18,6 +18,8 @@ describe('PromQueryField', () => {
|
||||
languageProvider: {
|
||||
start: () => Promise.resolve([]),
|
||||
syntax: () => {},
|
||||
getLabelKeys: () => [],
|
||||
metrics: [],
|
||||
},
|
||||
} as unknown) as DataSourceInstanceSettings<PromOptions>;
|
||||
|
||||
@@ -40,6 +42,8 @@ describe('PromQueryField', () => {
|
||||
languageProvider: {
|
||||
start: () => Promise.resolve([]),
|
||||
syntax: () => {},
|
||||
getLabelKeys: () => [],
|
||||
metrics: [],
|
||||
},
|
||||
} as unknown) as DataSourceInstanceSettings<PromOptions>;
|
||||
const queryField = render(
|
||||
@@ -75,8 +79,6 @@ describe('PromQueryField', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
checkMetricsInCascader(await screen.findByRole('button'), metrics);
|
||||
|
||||
const changedMetrics = ['baz', 'moo'];
|
||||
queryField.rerender(
|
||||
<PromQueryField
|
||||
@@ -88,162 +90,9 @@ describe('PromQueryField', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// If we check the cascader right away it should be in loading state
|
||||
let cascader = screen.getByRole('button');
|
||||
expect(cascader.textContent).toContain('Loading');
|
||||
checkMetricsInCascader(await screen.findByRole('button'), changedMetrics);
|
||||
});
|
||||
|
||||
it('does not refreshes metrics when after rounding to minute time range does not change', async () => {
|
||||
const defaultProps = {
|
||||
query: { expr: '', refId: '' },
|
||||
onRunQuery: () => {},
|
||||
onChange: () => {},
|
||||
history: [],
|
||||
};
|
||||
const metrics = ['foo', 'bar'];
|
||||
const changedMetrics = ['foo', 'baz'];
|
||||
const range = {
|
||||
from: dateTime('2020-10-28T00:00:00Z'),
|
||||
to: dateTime('2020-10-28T01:00:00Z'),
|
||||
};
|
||||
|
||||
const languageProvider = makeLanguageProvider({ metrics: [metrics, changedMetrics] });
|
||||
const queryField = render(
|
||||
<PromQueryField
|
||||
// @ts-ignore
|
||||
datasource={{ languageProvider }}
|
||||
range={{
|
||||
...range,
|
||||
raw: range,
|
||||
}}
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
checkMetricsInCascader(await screen.findByRole('button'), metrics);
|
||||
|
||||
const newRange = {
|
||||
from: dateTime('2020-10-28T00:00:01Z'),
|
||||
to: dateTime('2020-10-28T01:00:01Z'),
|
||||
};
|
||||
queryField.rerender(
|
||||
<PromQueryField
|
||||
// @ts-ignore
|
||||
datasource={{ languageProvider }}
|
||||
range={{
|
||||
...newRange,
|
||||
raw: newRange,
|
||||
}}
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
let cascader = screen.getByRole('button');
|
||||
// Should not show loading
|
||||
expect(cascader.textContent).toContain('Metrics');
|
||||
checkMetricsInCascader(await screen.findByRole('button'), metrics);
|
||||
});
|
||||
|
||||
it('refreshes metrics when time range changes but dont show loading state', async () => {
|
||||
const defaultProps = {
|
||||
query: { expr: '', refId: '' },
|
||||
onRunQuery: () => {},
|
||||
onChange: () => {},
|
||||
history: [],
|
||||
};
|
||||
const metrics = ['foo', 'bar'];
|
||||
const changedMetrics = ['baz', 'moo'];
|
||||
const range = {
|
||||
from: dateTime('2020-10-28T00:00:00Z'),
|
||||
to: dateTime('2020-10-28T01:00:00Z'),
|
||||
};
|
||||
|
||||
const languageProvider = makeLanguageProvider({ metrics: [metrics, changedMetrics] });
|
||||
const queryField = render(
|
||||
<PromQueryField
|
||||
// @ts-ignore
|
||||
datasource={{ languageProvider }}
|
||||
range={{
|
||||
...range,
|
||||
raw: range,
|
||||
}}
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
checkMetricsInCascader(await screen.findByRole('button'), metrics);
|
||||
|
||||
const newRange = {
|
||||
from: dateTime('2020-10-28T01:00:00Z'),
|
||||
to: dateTime('2020-10-28T02:00:00Z'),
|
||||
};
|
||||
queryField.rerender(
|
||||
<PromQueryField
|
||||
// @ts-ignore
|
||||
datasource={{ languageProvider }}
|
||||
range={{
|
||||
...newRange,
|
||||
raw: newRange,
|
||||
}}
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
let cascader = screen.getByRole('button');
|
||||
// Should not show loading
|
||||
expect(cascader.textContent).toContain('Metrics');
|
||||
checkMetricsInCascader(cascader, metrics);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupMetricsByPrefix()', () => {
|
||||
it('returns an empty group for no metrics', () => {
|
||||
expect(groupMetricsByPrefix([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns options grouped by prefix', () => {
|
||||
expect(groupMetricsByPrefix(['foo_metric'])).toMatchObject([
|
||||
{
|
||||
value: 'foo',
|
||||
children: [
|
||||
{
|
||||
value: 'foo_metric',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns options grouped by prefix with metadata', () => {
|
||||
expect(groupMetricsByPrefix(['foo_metric'], { foo_metric: [{ type: 'TYPE', help: 'my help' }] })).toMatchObject([
|
||||
{
|
||||
value: 'foo',
|
||||
children: [
|
||||
{
|
||||
value: 'foo_metric',
|
||||
title: 'foo_metric\nTYPE\nmy help',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns options without prefix as toplevel option', () => {
|
||||
expect(groupMetricsByPrefix(['metric'])).toMatchObject([
|
||||
{
|
||||
value: 'metric',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns recording rules grouped separately', () => {
|
||||
expect(groupMetricsByPrefix([':foo_metric:'])).toMatchObject([
|
||||
{
|
||||
value: RECORDING_RULES_GROUP,
|
||||
children: [
|
||||
{
|
||||
value: ':foo_metric:',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
// If we check the label browser right away it should be in loading state
|
||||
let labelBrowser = screen.getByRole('button');
|
||||
expect(labelBrowser.textContent).toContain('Loading');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,17 +103,10 @@ function makeLanguageProvider(options: { metrics: string[][] }) {
|
||||
metrics: [],
|
||||
metricsMetadata: {},
|
||||
lookupsDisabled: false,
|
||||
getLabelKeys: () => [],
|
||||
start() {
|
||||
this.metrics = metricsStack.shift();
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
} as any) as PromQlLanguageProvider;
|
||||
}
|
||||
|
||||
function checkMetricsInCascader(cascader: HTMLElement, metrics: string[]) {
|
||||
fireEvent.keyDown(cascader, { keyCode: 40 });
|
||||
let listNodes = screen.getAllByRole('menuitem');
|
||||
for (const node of listNodes) {
|
||||
expect(metrics).toContain(node.innerHTML);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { chain } from 'lodash';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import { Plugin } from 'slate';
|
||||
import {
|
||||
ButtonCascader,
|
||||
CascaderOption,
|
||||
SlatePrism,
|
||||
TypeaheadInput,
|
||||
TypeaheadOutput,
|
||||
@@ -12,12 +9,13 @@ import {
|
||||
BracesPlugin,
|
||||
DOMUtil,
|
||||
SuggestionsState,
|
||||
Icon,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import { LanguageMap, languages as prismLanguages } from 'prismjs';
|
||||
|
||||
// dom also includes Element polyfills
|
||||
import { PromQuery, PromOptions, PromMetricsMetadata } from '../types';
|
||||
import { PromQuery, PromOptions } from '../types';
|
||||
import { roundMsToMin } from '../language_utils';
|
||||
import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise';
|
||||
import {
|
||||
@@ -29,11 +27,11 @@ import {
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { PrometheusDatasource } from '../datasource';
|
||||
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
|
||||
|
||||
const HISTOGRAM_GROUP = '__histograms__';
|
||||
export const RECORDING_RULES_GROUP = '__recording_rules__';
|
||||
|
||||
function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, metrics: string[]) {
|
||||
function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) {
|
||||
if (metricsLookupDisabled) {
|
||||
return '(Disabled)';
|
||||
}
|
||||
@@ -42,56 +40,11 @@ function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, metr
|
||||
return 'Loading metrics...';
|
||||
}
|
||||
|
||||
if (metrics && metrics.length === 0) {
|
||||
if (!hasMetrics) {
|
||||
return '(No metrics found)';
|
||||
}
|
||||
|
||||
return 'Metrics';
|
||||
}
|
||||
|
||||
function addMetricsMetadata(metric: string, metadata?: PromMetricsMetadata): CascaderOption {
|
||||
const option: CascaderOption = { label: metric, value: metric };
|
||||
if (metadata && metadata[metric]) {
|
||||
const { type = '', help } = metadata[metric][0];
|
||||
option.title = [metric, type.toUpperCase(), help].join('\n');
|
||||
}
|
||||
return option;
|
||||
}
|
||||
|
||||
export function groupMetricsByPrefix(metrics: string[], metadata?: PromMetricsMetadata): CascaderOption[] {
|
||||
// Filter out recording rules and insert as first option
|
||||
const ruleRegex = /:\w+:/;
|
||||
const ruleNames = metrics.filter((metric) => ruleRegex.test(metric));
|
||||
const rulesOption = {
|
||||
label: 'Recording rules',
|
||||
value: RECORDING_RULES_GROUP,
|
||||
children: ruleNames
|
||||
.slice()
|
||||
.sort()
|
||||
.map((name) => ({ label: name, value: name })),
|
||||
};
|
||||
|
||||
const options = ruleNames.length > 0 ? [rulesOption] : [];
|
||||
|
||||
const delimiter = '_';
|
||||
const metricsOptions = chain(metrics)
|
||||
.filter((metric: string) => !ruleRegex.test(metric))
|
||||
.groupBy((metric: string) => metric.split(delimiter)[0])
|
||||
.map(
|
||||
(metricsForPrefix: string[], prefix: string): CascaderOption => {
|
||||
const prefixIsMetric = metricsForPrefix.length === 1 && metricsForPrefix[0] === prefix;
|
||||
const children = prefixIsMetric ? [] : metricsForPrefix.sort().map((m) => addMetricsMetadata(m, metadata));
|
||||
return {
|
||||
children,
|
||||
label: prefix,
|
||||
value: prefix,
|
||||
};
|
||||
}
|
||||
)
|
||||
.sortBy('label')
|
||||
.value();
|
||||
|
||||
return [...options, ...metricsOptions];
|
||||
return 'Metrics browser';
|
||||
}
|
||||
|
||||
export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string {
|
||||
@@ -127,7 +80,7 @@ interface PromQueryFieldProps extends ExploreQueryFieldProps<PrometheusDatasourc
|
||||
}
|
||||
|
||||
interface PromQueryFieldState {
|
||||
metricsOptions: any[];
|
||||
labelBrowserVisible: boolean;
|
||||
syntaxLoaded: boolean;
|
||||
hint: QueryHint | null;
|
||||
}
|
||||
@@ -151,7 +104,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
];
|
||||
|
||||
this.state = {
|
||||
metricsOptions: [],
|
||||
labelBrowserVisible: false,
|
||||
syntaxLoaded: false,
|
||||
hint: null,
|
||||
};
|
||||
@@ -181,7 +134,6 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
// We reset this only on DS change so we do not flesh loading state on every rangeChange which happens on every
|
||||
// query run if using relative range.
|
||||
this.setState({
|
||||
metricsOptions: [],
|
||||
syntaxLoaded: false,
|
||||
});
|
||||
}
|
||||
@@ -247,26 +199,12 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
return false;
|
||||
}
|
||||
|
||||
onChangeMetrics = (values: string[], selectedOptions: CascaderOption[]) => {
|
||||
let query;
|
||||
if (selectedOptions.length === 1) {
|
||||
const selectedOption = selectedOptions[0];
|
||||
if (!selectedOption.children || selectedOption.children.length === 0) {
|
||||
query = selectedOption.value;
|
||||
} else {
|
||||
// Ignore click on group
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const prefix = selectedOptions[0].value;
|
||||
const metric = selectedOptions[1].value;
|
||||
if (prefix === HISTOGRAM_GROUP) {
|
||||
query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;
|
||||
} else {
|
||||
query = metric;
|
||||
}
|
||||
}
|
||||
this.onChangeQuery(query, true);
|
||||
/**
|
||||
* TODO #33976: Remove this, add histogram group (query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;)
|
||||
*/
|
||||
onChangeLabelBrowser = (selector: string) => {
|
||||
this.onChangeQuery(selector, true);
|
||||
this.setState({ labelBrowserVisible: false });
|
||||
};
|
||||
|
||||
onChangeQuery = (value: string, override?: boolean) => {
|
||||
@@ -282,6 +220,10 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
}
|
||||
};
|
||||
|
||||
onClickChooserButton = () => {
|
||||
this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible }));
|
||||
};
|
||||
|
||||
onClickHintFix = () => {
|
||||
const { datasource, query, onChange, onRunQuery } = this.props;
|
||||
const { hint } = this.state;
|
||||
@@ -294,24 +236,13 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
const {
|
||||
datasource: { languageProvider },
|
||||
} = this.props;
|
||||
const { histogramMetrics, metrics, metricsMetadata } = languageProvider;
|
||||
const { metrics } = languageProvider;
|
||||
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build metrics tree
|
||||
const metricsByPrefix = groupMetricsByPrefix(metrics, metricsMetadata);
|
||||
const histogramOptions = histogramMetrics.map((hm: any) => ({ label: hm, value: hm }));
|
||||
const metricsOptions =
|
||||
histogramMetrics.length > 0
|
||||
? [
|
||||
{ label: 'Histograms', value: HISTOGRAM_GROUP, children: histogramOptions, isLeaf: false },
|
||||
...metricsByPrefix,
|
||||
]
|
||||
: metricsByPrefix;
|
||||
|
||||
this.setState({ metricsOptions, syntaxLoaded: true });
|
||||
this.setState({ syntaxLoaded: true });
|
||||
};
|
||||
|
||||
onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
|
||||
@@ -341,19 +272,24 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
query,
|
||||
ExtraFieldElement,
|
||||
} = this.props;
|
||||
const { metricsOptions, syntaxLoaded, hint } = this.state;
|
||||
const { labelBrowserVisible, syntaxLoaded, hint } = this.state;
|
||||
const cleanText = languageProvider ? languageProvider.cleanText : undefined;
|
||||
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, metricsOptions);
|
||||
const buttonDisabled = !(syntaxLoaded && metricsOptions && metricsOptions.length > 0);
|
||||
const hasMetrics = languageProvider.metrics.length > 0;
|
||||
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
|
||||
const buttonDisabled = !(syntaxLoaded && hasMetrics);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1">
|
||||
<div className="gf-form flex-shrink-0 min-width-5">
|
||||
<ButtonCascader options={metricsOptions} disabled={buttonDisabled} onChange={this.onChangeMetrics}>
|
||||
{chooserText}
|
||||
</ButtonCascader>
|
||||
</div>
|
||||
<button
|
||||
className="gf-form-label query-keyword pointer"
|
||||
onClick={this.onClickChooserButton}
|
||||
disabled={buttonDisabled}
|
||||
>
|
||||
{chooserText}
|
||||
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
|
||||
</button>
|
||||
|
||||
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15">
|
||||
<QueryField
|
||||
additionalPlugins={this.plugins}
|
||||
@@ -370,6 +306,12 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{labelBrowserVisible && (
|
||||
<div className="gf-form">
|
||||
<PrometheusMetricsBrowser languageProvider={languageProvider} onChange={this.onChangeLabelBrowser} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ExtraFieldElement}
|
||||
{hint ? (
|
||||
<div className="query-row-break">
|
||||
|
||||
@@ -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);
|
||||
@@ -7,6 +7,8 @@ exports[`PromExploreQueryEditor should render component 1`] = `
|
||||
datasource={
|
||||
Object {
|
||||
"languageProvider": Object {
|
||||
"getLabelKeys": [Function],
|
||||
"metrics": Array [],
|
||||
"syntax": [Function],
|
||||
},
|
||||
}
|
||||
@@ -63,6 +65,8 @@ exports[`PromExploreQueryEditor should render component 1`] = `
|
||||
datasource={
|
||||
Object {
|
||||
"languageProvider": Object {
|
||||
"getLabelKeys": [Function],
|
||||
"metrics": Array [],
|
||||
"syntax": [Function],
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user