From 4b0b69292e2044e3dff17b4d4eb625aaacfe86d9 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 May 2021 11:49:20 +0200 Subject: [PATCH] Prometheus: Metrics browser (#33847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 --- .../loki/components/LokiLabelBrowser.tsx | 6 +- .../loki/components/LokiQueryField.tsx | 4 +- .../LokiExploreQueryEditor.test.tsx.snap | 2 +- .../datasource/loki/language_provider.test.ts | 2 +- .../datasource/loki/language_provider.ts | 25 +- .../prometheus/components/Label.tsx | 123 ++++ .../PromExploreQueryEditor.test.tsx | 2 + .../components/PromQueryField.test.tsx | 180 +---- .../prometheus/components/PromQueryField.tsx | 136 ++-- .../PrometheusMetricsBrowser.test.tsx | 265 ++++++++ .../components/PrometheusMetricsBrowser.tsx | 633 ++++++++++++++++++ .../PromExploreQueryEditor.test.tsx.snap | 4 + .../datasource/prometheus/datasource.ts | 6 +- .../prometheus/language_provider.test.ts | 12 +- .../prometheus/language_provider.ts | 103 +-- 15 files changed, 1171 insertions(+), 332 deletions(-) create mode 100644 public/app/plugins/datasource/prometheus/components/Label.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx create mode 100644 public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx diff --git a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx index 3f03e8136a3..80ffc5c8ec2 100644 --- a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx +++ b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx @@ -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 { + onChangeLabelBrowser = (selector: string) => { this.onChangeQuery(selector, true); this.setState({ labelBrowserVisible: false }); }; @@ -174,7 +174,7 @@ export class LokiQueryField extends React.PureComponent {labelBrowserVisible && (
- +
)} diff --git a/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap b/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap index 5273e5490e0..a192fdc9e40 100644 --- a/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/loki/components/__snapshots__/LokiExploreQueryEditor.test.tsx.snap @@ -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 { diff --git a/public/app/plugins/datasource/loki/language_provider.test.ts b/public/app/plugins/datasource/loki/language_provider.test.ts index 9d8476903f6..9d162ac7db0 100644 --- a/public/app/plugins/datasource/loki/language_provider.test.ts +++ b/public/app/plugins/datasource/loki/language_provider.test.ts @@ -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); }); diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index 012b2c5b9e9..9df235d0bdf 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -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 { + async fetchLabels(): Promise { 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 ?? []; } } diff --git a/public/app/plugins/datasource/prometheus/components/Label.tsx b/public/app/plugins/datasource/prometheus/components/Label.tsx new file mode 100644 index 00000000000..43831b23fc6 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/Label.tsx @@ -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) => void; + +export interface Props extends Omit, '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( + ({ 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) => { + 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 ( + + ); + } +); + +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; + } + `, +}); diff --git a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx index 6501fe7f9a1..ea45b588f86 100644 --- a/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromExploreQueryEditor.test.tsx @@ -10,6 +10,8 @@ const setup = (renderMethod: any, propOverrides?: object) => { const datasourceMock: unknown = { languageProvider: { syntax: () => {}, + getLabelKeys: () => [], + metrics: [], }, }; const datasource: PrometheusDatasource = datasourceMock as PrometheusDatasource; diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx index b50a44564f9..f05bf5712d3 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx @@ -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; @@ -40,6 +42,8 @@ describe('PromQueryField', () => { languageProvider: { start: () => Promise.resolve([]), syntax: () => {}, + getLabelKeys: () => [], + metrics: [], }, } as unknown) as DataSourceInstanceSettings; const queryField = render( @@ -75,8 +79,6 @@ describe('PromQueryField', () => { /> ); - checkMetricsInCascader(await screen.findByRole('button'), metrics); - const changedMetrics = ['baz', 'moo']; queryField.rerender( { /> ); - // 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( - - ); - checkMetricsInCascader(await screen.findByRole('button'), metrics); - - const newRange = { - from: dateTime('2020-10-28T00:00:01Z'), - to: dateTime('2020-10-28T01:00:01Z'), - }; - queryField.rerender( - - ); - 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( - - ); - checkMetricsInCascader(await screen.findByRole('button'), metrics); - - const newRange = { - from: dateTime('2020-10-28T01:00:00Z'), - to: dateTime('2020-10-28T02:00:00Z'), - }; - queryField.rerender( - - ); - 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); - } -} diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index ec08fab5f2a..3b4da651b87 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx @@ -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 { - 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 { + 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 ({ 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 => { @@ -341,19 +272,24 @@ class PromQueryField extends React.PureComponent 0); + const hasMetrics = languageProvider.metrics.length > 0; + const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics); + const buttonDisabled = !(syntaxLoaded && hasMetrics); return ( <>
-
- - {chooserText} - -
+ +
+ {labelBrowserVisible && ( +
+ +
+ )} + {ExtraFieldElement} {hint ? (
diff --git a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx new file mode 100644 index 00000000000..bb842463fa0 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx @@ -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(); + // 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(); + // 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(); + // 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(); + // 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)'); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx new file mode 100644 index 00000000000..f6b83a569c9 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx @@ -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, + 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 = 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 { + state = { + labels: [] as SelectableLabel[], + labelSearchTerm: '', + metricSearchTerm: '', + status: 'Ready', + error: '', + validationStatus: '', + valueSearchTerm: '', + }; + + onChangeLabelSearch = (event: ChangeEvent) => { + this.setState({ labelSearchTerm: event.target.value }); + }; + + onChangeMetricSearch = (event: ChangeEvent) => { + this.setState({ metricSearchTerm: event.target.value }); + }; + + onChangeValueSearch = (event: ChangeEvent) => { + 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) => { + const label = this.state.labels.find((l) => l.name === name); + if (!label) { + return; + } + // Toggle selected state + const selected = !label.selected; + let nextValue: Partial = { 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) => { + 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) => { + // 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, 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 ( +
+ +
+ ); + } + + // 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 ( +
+ +
+
+ +
+ +
+
+ (metrics!.values as FacettableValue[])[i].name} + width={300} + className={styles.valueList} + > + {({ index, style }) => { + const value = metrics?.values?.[index]; + if (!value) { + return null; + } + return ( +
+ +
+ ); + }} +
+
+
+
+ +
+
+ +
+ +
+
+ {nonMetricLabels.map((label) => ( +
+
+
+ +
+ +
+
+ {selectedLabels.map((label) => ( +
+
+
+ (label.values as FacettableValue[])[i].name} + width={200} + className={styles.valueList} + > + {({ index, style }) => { + const value = label.values?.[index]; + if (!value) { + return null; + } + return ( +
+ +
+ ); + }} +
+
+ ))} +
+
+
+
+ +
+ +
+ {selector} +
+ {validationStatus &&
{validationStatus}
} + + + + + +
+ {error || status} +
+
+
+
+ ); + } +} + +export const PrometheusMetricsBrowser = withTheme(UnthemedPrometheusMetricsBrowser); diff --git a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap index 24118aab039..9f69a2cb974 100644 --- a/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap +++ b/public/app/plugins/datasource/prometheus/components/__snapshots__/PromExploreQueryEditor.test.tsx.snap @@ -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], }, } diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 9a95731b414..1d5f15f398e 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -804,11 +804,11 @@ export class PrometheusDatasource extends DataSourceApi 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(), }; } diff --git a/public/app/plugins/datasource/prometheus/language_provider.test.ts b/public/app/plugins/datasource/prometheus/language_provider.test.ts index 2bbc9ddf89c..e29ca883f39 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.test.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.test.ts @@ -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); diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index c8ae4f535c2..aa88e221acb 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/public/app/plugins/datasource/prometheus/language_provider.ts @@ -69,6 +69,8 @@ export default class PromQlLanguageProvider extends LanguageProvider { metricsMetadata?: PromMetricsMetadata; startTask: Promise; 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 = {}; // 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> { 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> => { - const tRange = this.datasource.getTimeRange(); - const params = { - start: tRange['start'].toString(), - end: tRange['end'].toString(), - }; + fetchLabelValues = async (key: string): Promise => { + 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 { + return await this.fetchLabelValues(key); + } + + /** + * Fetches all label keys + */ + async fetchLabels(): Promise { + 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> => { - 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>> => { + 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] }), {}); }); }