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
David 4 years ago committed by GitHub
parent 8d442c9b44
commit 4b0b69292e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx
  2. 4
      public/app/plugins/datasource/loki/components/LokiQueryField.tsx
  3. 2
      public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap
  4. 2
      public/app/plugins/datasource/loki/language_provider.test.ts
  5. 25
      public/app/plugins/datasource/loki/language_provider.ts
  6. 123
      public/app/plugins/datasource/prometheus/components/Label.tsx
  7. 2
      public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx
  8. 180
      public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx
  9. 136
      public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
  10. 265
      public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx
  11. 633
      public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx
  12. 4
      public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap
  13. 6
      public/app/plugins/datasource/prometheus/datasource.ts
  14. 12
      public/app/plugins/datasource/prometheus/language_provider.test.ts
  15. 103
      public/app/plugins/datasource/prometheus/language_provider.ts

@ -1,6 +1,7 @@
import React, { ChangeEvent } from 'react';
import { Button, HorizontalGroup, Input, Label, LoadingPlaceholder, stylesFactory, withTheme } from '@grafana/ui';
import LokiLanguageProvider from '../language_provider';
import PromQlLanguageProvider from '../../prometheus/language_provider';
import { css, cx } from '@emotion/css';
import store from 'app/core/store';
import { FixedSizeList } from 'react-window';
@ -16,7 +17,8 @@ const EMPTY_SELECTOR = '{}';
export const LAST_USED_LABELS_KEY = 'grafana.datasources.loki.browser.labels';
export interface BrowserProps {
languageProvider: LokiLanguageProvider;
// TODO #33976: Is it possible to use a common interface here? For example: LabelsLanguageProvider
languageProvider: LokiLanguageProvider | PromQlLanguageProvider;
onChange: (selector: string) => void;
theme: GrafanaTheme;
autoSelect?: number;
@ -333,7 +335,7 @@ export class UnthemedLokiLabelBrowser extends React.Component<BrowserProps, Brow
this.updateLabelState(lastFacetted, { loading: true }, `Facetting labels for ${selector}`);
}
try {
const possibleLabels = await languageProvider.fetchSeriesLabels(selector);
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.');

@ -97,7 +97,7 @@ export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, Lok
this.setState({ labelsLoaded: true });
}
onChangeLogLabels = (selector: string) => {
onChangeLabelBrowser = (selector: string) => {
this.onChangeQuery(selector, true);
this.setState({ labelBrowserVisible: false });
};
@ -174,7 +174,7 @@ export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, Lok
</div>
{labelBrowserVisible && (
<div className="gf-form">
<LokiLabelBrowser languageProvider={lokiLanguageProvider} onChange={this.onChangeLogLabels} />
<LokiLabelBrowser languageProvider={lokiLanguageProvider} onChange={this.onChangeLabelBrowser} />
</div>
)}

@ -68,6 +68,7 @@ exports[`LokiExploreQueryEditor should render component 1`] = `
"getBeginningCompletionItems": [Function],
"getPipeCompletionItem": [Function],
"getTermCompletionItems": [Function],
"labelFetchTs": 0,
"labelKeys": Array [],
"labelsCache": LRUCache {
Symbol(max): 10,
@ -85,7 +86,6 @@ exports[`LokiExploreQueryEditor should render component 1`] = `
},
Symbol(length): 0,
},
"logLabelFetchTs": 0,
"lookupsDisabled": false,
"request": [Function],
"seriesCache": LRUCache {

@ -221,7 +221,7 @@ describe('Request URL', () => {
const datasourceSpy = jest.spyOn(datasourceWithLabels as any, 'metadataRequest');
const instance = new LanguageProvider(datasourceWithLabels);
instance.fetchLogLabels();
instance.fetchLabels();
const expectedUrl = '/loki/api/v1/label';
expect(datasourceSpy).toHaveBeenCalledWith(expectedUrl, rangeParams);
});

@ -70,7 +70,7 @@ export function addHistoryMetadata(item: CompletionItem, history: LokiHistoryIte
export default class LokiLanguageProvider extends LanguageProvider {
labelKeys: string[];
logLabelFetchTs: number;
labelFetchTs: number;
started = false;
datasource: LokiDatasource;
lookupsDisabled = false; // Dynamically set to true for big/slow instances
@ -88,7 +88,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
this.datasource = datasource;
this.labelKeys = [];
this.logLabelFetchTs = 0;
this.labelFetchTs = 0;
Object.assign(this, initialValues);
}
@ -116,7 +116,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
*/
start = () => {
if (!this.startTask) {
this.startTask = this.fetchLogLabels().then(() => {
this.startTask = this.fetchLabels().then(() => {
this.started = true;
return [];
});
@ -415,12 +415,11 @@ export default class LokiLanguageProvider extends LanguageProvider {
/**
* Fetches all label keys
* @param absoluteRange Fetches
*/
async fetchLogLabels(): Promise<any> {
async fetchLabels(): Promise<string[]> {
const url = '/loki/api/v1/label';
const timeRange = this.datasource.getTimeRangeParams();
this.logLabelFetchTs = Date.now().valueOf();
this.labelFetchTs = Date.now().valueOf();
const res = await this.request(url, timeRange);
if (Array.isArray(res)) {
@ -431,8 +430,8 @@ export default class LokiLanguageProvider extends LanguageProvider {
}
async refreshLogLabels(forceRefresh?: boolean) {
if ((this.labelKeys && Date.now().valueOf() - this.logLabelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) {
await this.fetchLogLabels();
if ((this.labelKeys && Date.now().valueOf() - this.labelFetchTs > LABEL_REFRESH_INTERVAL) || forceRefresh) {
await this.fetchLabels();
}
}
@ -495,17 +494,17 @@ export default class LokiLanguageProvider extends LanguageProvider {
const cacheKey = this.generateCacheKey(url, start, end, key);
const params = { start, end };
let labelValue = this.labelsCache.get(cacheKey);
if (!labelValue) {
let labelValues = this.labelsCache.get(cacheKey);
if (!labelValues) {
// Clear value when requesting new one. Empty object being truthy also makes sure we don't request twice.
this.labelsCache.set(cacheKey, []);
const res = await this.request(url, params);
if (Array.isArray(res)) {
labelValue = res.slice().sort();
this.labelsCache.set(cacheKey, labelValue);
labelValues = res.slice().sort();
this.labelsCache.set(cacheKey, labelValues);
}
}
return labelValue ?? [];
return labelValues ?? [];
}
}

@ -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],
},
}

@ -804,11 +804,11 @@ export class PrometheusDatasource extends DataSourceApi<PromQuery, PromOptions>
return Math.ceil(date.valueOf() / 1000);
}
getTimeRange(): { start: number; end: number } {
getTimeRangeParams(): { start: string; end: string } {
const range = this.timeSrv.timeRange();
return {
start: this.getPrometheusTime(range.from, false),
end: this.getPrometheusTime(range.to, true),
start: this.getPrometheusTime(range.from, false).toString(),
end: this.getPrometheusTime(range.to, true).toString(),
};
}

@ -10,7 +10,7 @@ import { SearchFunctionType } from '@grafana/ui';
describe('Language completion provider', () => {
const datasource: PrometheusDatasource = ({
metadataRequest: () => ({ data: { data: [] as any[] } }),
getTimeRange: () => ({ start: 0, end: 1 }),
getTimeRangeParams: () => ({ start: '0', end: '1' }),
} as any) as PrometheusDatasource;
describe('cleanText', () => {
@ -249,7 +249,7 @@ describe('Language completion provider', () => {
it('returns label suggestions on label context and metric', async () => {
const datasources: PrometheusDatasource = ({
metadataRequest: () => ({ data: { data: [{ __name__: 'metric', bar: 'bazinga' }] as any[] } }),
getTimeRange: () => ({ start: 0, end: 1 }),
getTimeRangeParams: () => ({ start: '0', end: '1' }),
} as any) as PrometheusDatasource;
const instance = new LanguageProvider(datasources);
const value = Plain.deserialize('metric{}');
@ -282,7 +282,7 @@ describe('Language completion provider', () => {
],
},
}),
getTimeRange: () => ({ start: 0, end: 1 }),
getTimeRangeParams: () => ({ start: '0', end: '1' }),
} as any) as PrometheusDatasource;
const instance = new LanguageProvider(datasource);
const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",__name__="metric",}');
@ -519,7 +519,7 @@ describe('Language completion provider', () => {
it('does not re-fetch default labels', async () => {
const datasource: PrometheusDatasource = ({
metadataRequest: jest.fn(() => ({ data: { data: [] as any[] } })),
getTimeRange: jest.fn(() => ({ start: 0, end: 1 })),
getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
} as any) as PrometheusDatasource;
const instance = new LanguageProvider(datasource);
@ -545,7 +545,7 @@ describe('Language completion provider', () => {
it('does not issue any metadata requests when lookup is disabled', async () => {
const datasource: PrometheusDatasource = ({
metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })),
getTimeRange: jest.fn(() => ({ start: 0, end: 1 })),
getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
lookupsDisabled: true,
} as any) as PrometheusDatasource;
const instance = new LanguageProvider(datasource);
@ -568,7 +568,7 @@ describe('Language completion provider', () => {
it('issues metadata requests when lookup is not disabled', async () => {
const datasource: PrometheusDatasource = ({
metadataRequest: jest.fn(() => ({ data: { data: ['foo', 'bar'] as string[] } })),
getTimeRange: jest.fn(() => ({ start: 0, end: 1 })),
getTimeRangeParams: jest.fn(() => ({ start: '0', end: '1' })),
lookupsDisabled: false,
} as any) as PrometheusDatasource;
const instance = new LanguageProvider(datasource);

@ -69,6 +69,8 @@ export default class PromQlLanguageProvider extends LanguageProvider {
metricsMetadata?: PromMetricsMetadata;
startTask: Promise<any>;
datasource: PrometheusDatasource;
labelKeys: string[];
labelFetchTs: number;
/**
* Cache for labels of series. This is bit simplistic in the sense that it just counts responses each as a 1 and does
@ -115,20 +117,19 @@ export default class PromQlLanguageProvider extends LanguageProvider {
return [];
}
const tRange = this.datasource.getTimeRange();
const params = {
start: tRange['start'].toString(),
end: tRange['end'].toString(),
};
const url = `/api/v1/label/__name__/values`;
this.metrics = await this.request(url, [], params);
// TODO #33976: make those requests parallel
await this.fetchLabels();
this.metrics = await this.fetchLabelValues('__name__');
this.metricsMetadata = fixSummariesMetadata(await this.request('/api/v1/metadata', {}));
this.processHistogramMetrics(this.metrics);
return [];
};
getLabelKeys(): string[] {
return this.labelKeys;
}
processHistogramMetrics = (data: string[]) => {
const { values } = processHistogramLabels(data);
@ -308,12 +309,13 @@ export default class PromQlLanguageProvider extends LanguageProvider {
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
const labelValues = await this.getLabelValues(selector);
if (labelValues) {
const limitInfo = addLimitInfo(labelValues[0]);
const series = await this.getSeries(selector);
const labelKeys = Object.keys(series);
if (labelKeys.length > 0) {
const limitInfo = addLimitInfo(labelKeys);
suggestions.push({
label: `Labels${limitInfo}`,
items: Object.keys(labelValues).map(wrapLabel),
items: labelKeys.map(wrapLabel),
searchFunctionType: SearchFunctionType.Fuzzy,
});
}
@ -360,13 +362,13 @@ export default class PromQlLanguageProvider extends LanguageProvider {
const containsMetric = selector.includes('__name__=');
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
let labelValues;
let series: Record<string, string[]> = {};
// Query labels for selector
if (selector) {
labelValues = await this.getLabelValues(selector, !containsMetric);
series = await this.getSeries(selector, !containsMetric);
}
if (!labelValues) {
if (Object.keys(series).length === 0) {
console.warn(`Server did not return any values for selector = ${selector}`);
return { suggestions };
}
@ -375,18 +377,18 @@ export default class PromQlLanguageProvider extends LanguageProvider {
if ((text && isValueStart) || wrapperClasses.includes('attr-value')) {
// Label values
if (labelKey && labelValues[labelKey]) {
if (labelKey && series[labelKey]) {
context = 'context-label-values';
const limitInfo = addLimitInfo(labelValues[labelKey]);
const limitInfo = addLimitInfo(series[labelKey]);
suggestions.push({
label: `Label values for "${labelKey}"${limitInfo}`,
items: labelValues[labelKey].map(wrapLabel),
items: series[labelKey].map(wrapLabel),
searchFunctionType: SearchFunctionType.Fuzzy,
});
}
} else {
// Label keys
const labelKeys = labelValues ? Object.keys(labelValues) : containsMetric ? null : DEFAULT_KEYS;
const labelKeys = series ? Object.keys(series) : containsMetric ? null : DEFAULT_KEYS;
if (labelKeys) {
const possibleKeys = difference(labelKeys, existingKeys);
@ -407,34 +409,49 @@ export default class PromQlLanguageProvider extends LanguageProvider {
return { context, suggestions };
};
async getLabelValues(selector: string, withName?: boolean) {
async getSeries(selector: string, withName?: boolean): Promise<Record<string, string[]>> {
if (this.datasource.lookupsDisabled) {
return undefined;
return {};
}
try {
if (selector === EMPTY_SELECTOR) {
return await this.fetchDefaultLabels();
return await this.fetchDefaultSeries();
} else {
return await this.fetchSeriesLabels(selector, withName);
}
} catch (error) {
// TODO: better error handling
console.error(error);
return undefined;
return {};
}
}
fetchLabelValues = async (key: string): Promise<Record<string, string[]>> => {
const tRange = this.datasource.getTimeRange();
const params = {
start: tRange['start'].toString(),
end: tRange['end'].toString(),
};
fetchLabelValues = async (key: string): Promise<string[]> => {
const params = this.datasource.getTimeRangeParams();
const url = `/api/v1/label/${key}/values`;
const data = await this.request(url, [], params);
return { [key]: data };
return await this.request(url, [], params);
};
async getLabelValues(key: string): Promise<string[]> {
return await this.fetchLabelValues(key);
}
/**
* Fetches all label keys
*/
async fetchLabels(): Promise<string[]> {
const url = '/api/v1/labels';
const params = this.datasource.getTimeRangeParams();
this.labelFetchTs = Date.now().valueOf();
const res = await this.request(url, [], params);
if (Array.isArray(res)) {
this.labelKeys = res.slice().sort();
}
return [];
}
/**
* Fetch labels for a series. This is cached by it's args but also by the global timeRange currently selected as
* they can change over requested time.
@ -442,11 +459,10 @@ export default class PromQlLanguageProvider extends LanguageProvider {
* @param withName
*/
fetchSeriesLabels = async (name: string, withName?: boolean): Promise<Record<string, string[]>> => {
const tRange = this.datasource.getTimeRange();
const range = this.datasource.getTimeRangeParams();
const urlParams = {
...range,
'match[]': name,
start: tRange['start'].toString(),
end: tRange['end'].toString(),
};
const url = `/api/v1/series`;
// Cache key is a bit different here. We add the `withName` param and also round up to a minute the intervals.
@ -455,8 +471,8 @@ export default class PromQlLanguageProvider extends LanguageProvider {
// when user does not the newest values for a minute if already cached.
const cacheParams = new URLSearchParams({
'match[]': name,
start: roundSecToMin(tRange['start']).toString(),
end: roundSecToMin(tRange['end']).toString(),
start: roundSecToMin(parseInt(range.start, 10)).toString(),
end: roundSecToMin(parseInt(range.end, 10)).toString(),
withName: withName ? 'true' : 'false',
});
@ -471,13 +487,24 @@ export default class PromQlLanguageProvider extends LanguageProvider {
return value;
};
/**
* Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels.
* @param match
*/
fetchSeries = async (match: string): Promise<Array<Record<string, string>>> => {
const url = '/api/v1/series';
const range = this.datasource.getTimeRangeParams();
const params = { ...range, match };
return await this.request(url, {}, params);
};
/**
* Fetch this only one as we assume this won't change over time. This is cached differently from fetchSeriesLabels
* because we can cache more aggressively here and also we do not want to invalidate this cache the same way as in
* fetchSeriesLabels.
*/
fetchDefaultLabels = once(async () => {
fetchDefaultSeries = once(async () => {
const values = await Promise.all(DEFAULT_KEYS.map((key) => this.fetchLabelValues(key)));
return values.reduce((acc, value) => ({ ...acc, ...value }), {});
return DEFAULT_KEYS.reduce((acc, key, i) => ({ ...acc, [key]: values[i] }), {});
});
}

Loading…
Cancel
Save