From 8fadade4b8df480d6f55b414fb741baaf866e5ea Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Fri, 4 Apr 2025 18:58:46 +0200 Subject: [PATCH] Chore: Split PrometheusMetricsBrowser into small functional components (#101703) * convert it to functional component * useReducer * usePromQueryFieldEffects * clean up the code * remove localStorage provider * introduce usePromQueryFieldEffects.test.ts * simpler state management * remove mocks * linting + betterer * Split PrometheusMetricsBrowser into small functional components * prettier --- .../src/components/PromQueryField.tsx | 4 +- .../components/PrometheusMetricsBrowser.tsx | 734 ------------------ .../metrics-browser/LabelSelector.tsx | 53 ++ .../metrics-browser/MetricSelector.tsx | 91 +++ .../PrometheusMetricsBrowser.test.tsx | 139 +--- .../PrometheusMetricsBrowser.tsx | 373 +++++++++ .../metrics-browser/SelectorActions.tsx | 79 ++ .../metrics-browser/ValueSelector.tsx | 84 ++ .../metrics-browser/selectorBuilder.test.ts | 441 +++++++++++ .../metrics-browser/selectorBuilder.ts | 74 ++ .../src/components/metrics-browser/styles.ts | 78 ++ .../src/components/metrics-browser/types.ts | 45 ++ .../usePromQueryFieldEffects.test.ts | 40 +- .../components/usePromQueryFieldEffects.ts | 6 +- packages/grafana-prometheus/src/index.ts | 2 +- .../src/language_provider.test.ts | 2 +- .../src/language_provider.ts | 2 +- 17 files changed, 1366 insertions(+), 881 deletions(-) delete mode 100644 packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx create mode 100644 packages/grafana-prometheus/src/components/metrics-browser/LabelSelector.tsx create mode 100644 packages/grafana-prometheus/src/components/metrics-browser/MetricSelector.tsx rename packages/grafana-prometheus/src/components/{ => metrics-browser}/PrometheusMetricsBrowser.test.tsx (68%) create mode 100644 packages/grafana-prometheus/src/components/metrics-browser/PrometheusMetricsBrowser.tsx create mode 100644 packages/grafana-prometheus/src/components/metrics-browser/SelectorActions.tsx create mode 100644 packages/grafana-prometheus/src/components/metrics-browser/ValueSelector.tsx create mode 100644 packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.test.ts create mode 100644 packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.ts create mode 100644 packages/grafana-prometheus/src/components/metrics-browser/styles.ts create mode 100644 packages/grafana-prometheus/src/components/metrics-browser/types.ts diff --git a/packages/grafana-prometheus/src/components/PromQueryField.tsx b/packages/grafana-prometheus/src/components/PromQueryField.tsx index 6670563ea8a..b92a3c77a98 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.tsx @@ -11,8 +11,8 @@ import { PrometheusDatasource } from '../datasource'; import { getInitHints } from '../query_hints'; import { PromOptions, PromQuery } from '../types'; -import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser'; import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise'; +import { PrometheusMetricsBrowser } from './metrics-browser/PrometheusMetricsBrowser'; import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper'; import { useMetricsState } from './useMetricsState'; import { usePromQueryFieldEffects } from './usePromQueryFieldEffects'; @@ -136,7 +136,7 @@ export const PromQueryField = (props: PromQueryFieldProps) => { }; // Use our custom effects hook - usePromQueryFieldEffects(range, data?.series, refreshMetrics, refreshHint); + usePromQueryFieldEffects(languageProvider, range, data?.series, refreshMetrics, refreshHint); const { chooserText, buttonDisabled } = useMetricsState(datasource, languageProvider, syntaxLoaded); diff --git a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx deleted file mode 100644 index 5898a649ad5..00000000000 --- a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx +++ /dev/null @@ -1,734 +0,0 @@ -// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx -import { css, cx } from '@emotion/css'; -import { ChangeEvent } from 'react'; -import * as React from 'react'; -import { FixedSizeList } from 'react-window'; - -import { getDefaultTimeRange, GrafanaTheme2, TimeRange } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { - BrowserLabel as PromLabel, - Button, - Stack, - Input, - Label, - LoadingPlaceholder, - stylesFactory, - withTheme2, -} from '@grafana/ui'; - -import PromQlLanguageProvider from '../language_provider'; -import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../language_utils'; -import { isValidLegacyName, utf8Support } from '../utf8_support'; - -// Hard limit on labels to render -const EMPTY_SELECTOR = '{}'; -const METRIC_LABEL = '__name__'; -const LIST_ITEM_SIZE = 25; -const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels'; - -export interface BrowserProps { - languageProvider: PromQlLanguageProvider; - onChange: (selector: string) => void; - theme: GrafanaTheme2; - autoSelect?: number; - hide?: () => void; - timeRange?: TimeRange; -} - -interface BrowserState { - labels: SelectableLabel[]; - labelSearchTerm: string; - metricSearchTerm: string; - status: string; - error: string; - validationStatus: string; - valueSearchTerm: string; - seriesLimit?: string; -} - -export const DEFAULT_SERIES_LIMIT = '40000'; -export const REMOVE_SERIES_LIMIT = 'none'; - -interface FacettableValue { - name: string; - selected?: boolean; - details?: string; -} - -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: string[] = []; - 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( - `${utf8Support(label.name)}=~"${selectedValues.map(escapeLabelValueInRegexSelector).join('|')}"` - ); - } else if (selectedValues.length === 1) { - if (label.name === METRIC_LABEL) { - singleMetric = selectedValues[0]; - } else { - selectedLabels.push(`${utf8Support(label.name)}="${escapeLabelValueInExactSelector(selectedValues[0])}"`); - } - } - } - } - - const selectorParts: string[] = []; - const isLegacyName = singleMetric === '' || isValidLegacyName(singleMetric); - - if (isLegacyName) { - selectorParts.push(singleMetric, '{'); - } else { - selectorParts.push('{', `"${singleMetric}"`); - if (selectedLabels.length > 0) { - selectorParts.push(','); - } - } - - selectorParts.push(selectedLabels.join(','), '}'); - return selectorParts.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: GrafanaTheme2) => ({ - wrapper: css({ - backgroundColor: theme.colors.background.secondary, - padding: theme.spacing(1), - width: '100%', - }), - list: css({ - marginTop: theme.spacing(1), - display: 'flex', - flexWrap: 'wrap', - maxHeight: '200px', - overflow: 'auto', - alignContent: 'flex-start', - }), - section: css({ - '& + &': { - margin: `${theme.spacing(2)} 0`, - }, - position: 'relative', - }), - selector: css({ - fontFamily: theme.typography.fontFamilyMonospace, - marginBottom: theme.spacing(1), - }), - status: css({ - padding: theme.spacing(0.5), - color: theme.colors.text.secondary, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - /* using absolute positioning because flex interferes with ellipsis */ - position: 'absolute', - width: '50%', - right: 0, - textAlign: 'right', - opacity: 0, - [theme.transitions.handleMotion('no-preference', 'reduce')]: { - transition: 'opacity 100ms linear', - }, - }), - statusShowing: css({ - opacity: 1, - }), - error: css({ - color: theme.colors.error.main, - }), - valueList: css({ - marginRight: theme.spacing(1), - resize: 'horizontal', - }), - valueListWrapper: css({ - borderLeft: `1px solid ${theme.colors.border.medium}`, - margin: `${theme.spacing(1)} 0`, - padding: `${theme.spacing(1)} 0 ${theme.spacing(1)} ${theme.spacing(1)}`, - }), - valueListArea: css({ - display: 'flex', - flexWrap: 'wrap', - marginTop: theme.spacing(1), - }), - valueTitle: css({ - marginLeft: `-${theme.spacing(0.5)}`, - marginBottom: theme.spacing(1), - }), - validationStatus: css({ - padding: theme.spacing(0.5), - marginBottom: theme.spacing(1), - color: theme.colors.text.maxContrast, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: '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 { - valueListsRef = React.createRef(); - state: BrowserState = { - labels: [], - 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 }); - }; - - onChangeSeriesLimit = (event: ChangeEvent) => { - this.setState({ seriesLimit: event.target.value.trim() }); - }; - - 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}[$__rate_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: '', - }; - }); - localStorage.removeItem(LAST_USED_LABELS_KEY); - // Get metrics - this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR); - }; - - 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[] = JSON.parse(localStorage.getItem(LAST_USED_LABELS_KEY) ?? `[]`) ?? []; - languageProvider.start(this.props.timeRange).then(() => { - let rawLabels: string[] = languageProvider.getLabelKeys(); - // Get metrics - this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR); - // 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, EMPTY_SELECTOR); - } - }); - }); - }); - } - } - - 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); - localStorage.setItem(LAST_USED_LABELS_KEY, JSON.stringify(selectedLabels)); - - if (label.selected) { - // Refetch values for newly selected label... - if (!label.values) { - this.fetchValues(name, buildSelector(this.state.labels)); - } - } 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, selector) - ); - }); - } else { - // Do facetting - this.fetchSeries(selector, lastFacetted); - } - }; - - getTimeRange = (): TimeRange => { - return this.props.timeRange ?? getDefaultTimeRange(); - }; - - async fetchValues(name: string, selector: string) { - const { languageProvider } = this.props; - this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`); - try { - let rawValues = await languageProvider.getLabelValues(this.getTimeRange(), name); - // If selector changed, clear loading state and discard result by returning early - if (selector !== buildSelector(this.state.labels)) { - this.updateLabelState(name, { loading: false }); - return; - } - const values: FacettableValue[] = []; - const { metricsMetadata } = languageProvider; - for (const labelValue of rawValues) { - const value: FacettableValue = { name: labelValue }; - // Adding type/help text to metrics - if (name === METRIC_LABEL && metricsMetadata) { - const meta = metricsMetadata[labelValue]; - if (meta) { - value.details = `(${meta.type}) ${meta.help}`; - } - } - values.push(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( - this.getTimeRange(), - selector, - true, - this.state.seriesLimit - ); - // If selector changed, clear loading state and discard result by returning early - if (selector !== buildSelector(this.state.labels)) { - if (lastFacetted) { - this.updateLabelState(lastFacetted, { loading: false }); - } - return; - } - if (Object.keys(possibleLabels).length === 0) { - this.setState({ error: `Empty results, no matching label 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(this.getTimeRange(), selector); - this.setState({ validationStatus: `Selector is valid (${streams.length} series 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) { - 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) { - 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) { - 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; - const metricCount = metrics?.values?.length || 0; - - return ( -
- -
-
- -
- -
- -
- -
-
- metrics!.values![i].name} - width={300} - className={styles.valueList} - > - {({ index, style }) => { - const value = metrics?.values?.[index]; - if (!value) { - return null; - } - return ( -
- -
- ); - }} -
-
-
-
- -
-
- -
- -
- {/* Using fixed height here to prevent jumpy layout */} -
- {nonMetricLabels.map((label) => ( -
-
-
- -
- -
-
- {selectedLabels.map((label) => ( -
-
-
- label.values![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 = withTheme2(UnthemedPrometheusMetricsBrowser); diff --git a/packages/grafana-prometheus/src/components/metrics-browser/LabelSelector.tsx b/packages/grafana-prometheus/src/components/metrics-browser/LabelSelector.tsx new file mode 100644 index 00000000000..780150b7cfa --- /dev/null +++ b/packages/grafana-prometheus/src/components/metrics-browser/LabelSelector.tsx @@ -0,0 +1,53 @@ +import { ChangeEvent, MouseEvent } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Input, Label, BrowserLabel as PromLabel } from '@grafana/ui'; + +import { SelectableLabel } from './types'; + +interface LabelSelectorProps { + nonMetricLabels: SelectableLabel[]; + labelSearchTerm: string; + onChangeLabelSearch: (event: ChangeEvent) => void; + onClickLabel: (name: string, value: string | undefined, event: MouseEvent) => void; + styles: Record; +} + +export function LabelSelector({ + nonMetricLabels, + labelSearchTerm, + onChangeLabelSearch, + onClickLabel, + styles, +}: LabelSelectorProps) { + return ( +
+ +
+ +
+ {/* Using fixed height here to prevent jumpy layout */} +
+ {nonMetricLabels.map((label) => ( +
+
+ ); +} diff --git a/packages/grafana-prometheus/src/components/metrics-browser/MetricSelector.tsx b/packages/grafana-prometheus/src/components/metrics-browser/MetricSelector.tsx new file mode 100644 index 00000000000..704a3a86c39 --- /dev/null +++ b/packages/grafana-prometheus/src/components/metrics-browser/MetricSelector.tsx @@ -0,0 +1,91 @@ +import { ChangeEvent, MouseEvent } from 'react'; +import { FixedSizeList } from 'react-window'; + +import { selectors } from '@grafana/e2e-selectors'; +import { BrowserLabel as PromLabel, Input, Label } from '@grafana/ui'; + +import { LIST_ITEM_SIZE, SelectableLabel } from './types'; + +interface MetricSelectorProps { + metrics: SelectableLabel | undefined; + metricSearchTerm: string; + seriesLimit: string; + onChangeMetricSearch: (event: ChangeEvent) => void; + onChangeSeriesLimit: (event: ChangeEvent) => void; + onClickMetric: (name: string, value: string | undefined, event: MouseEvent) => void; + styles: Record; +} + +export function MetricSelector({ + metrics, + metricSearchTerm, + seriesLimit, + onChangeMetricSearch, + onChangeSeriesLimit, + onClickMetric, + styles, +}: MetricSelectorProps) { + const metricCount = metrics?.values?.length || 0; + + return ( +
+
+ +
+ +
+ +
+ +
+
+ metrics!.values![i].name} + width={300} + className={styles.valueList} + > + {({ index, style }) => { + const value = metrics?.values?.[index]; + if (!value) { + return null; + } + return ( +
+ +
+ ); + }} +
+
+
+
+ ); +} diff --git a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx b/packages/grafana-prometheus/src/components/metrics-browser/PrometheusMetricsBrowser.test.tsx similarity index 68% rename from packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx rename to packages/grafana-prometheus/src/components/metrics-browser/PrometheusMetricsBrowser.test.tsx index 240f849fb05..04305fe6640 100644 --- a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx +++ b/packages/grafana-prometheus/src/components/metrics-browser/PrometheusMetricsBrowser.test.tsx @@ -4,143 +4,10 @@ import userEvent from '@testing-library/user-event'; import { createTheme, getDefaultTimeRange, TimeRange } from '@grafana/data'; -import PromQlLanguageProvider from '../language_provider'; +import PromQlLanguageProvider from '../../language_provider'; -import { - BrowserProps, - buildSelector, - facetLabels, - SelectableLabel, - UnthemedPrometheusMetricsBrowser, -} from './PrometheusMetricsBrowser'; - -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('utf8 support', () => { - it('metric selector with utf8 metric', () => { - const labels: SelectableLabel[] = [ - { name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] }, - ]; - expect(buildSelector(labels)).toEqual('{"utf8.metric"}'); - }); - - it('metric selector with utf8 labels', () => { - const labels: SelectableLabel[] = [ - { name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }, - { name: 'utf8.label', selected: true, values: [{ name: 'baz', selected: true }] }, - ]; - expect(buildSelector(labels)).toEqual('foo{"utf8.label"="baz"}'); - }); - - it('metric selector with utf8 labels and metrics', () => { - const labels: SelectableLabel[] = [ - { name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] }, - { name: 'utf8.label', selected: true, values: [{ name: 'baz', selected: true }] }, - ]; - expect(buildSelector(labels)).toEqual('{"utf8.metric","utf8.label"="baz"}'); - }); - - it('metric selector with utf8 metric and with utf8/non-utf8 labels', () => { - const labels: SelectableLabel[] = [ - { name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] }, - { name: 'utf8.label', selected: true, values: [{ name: 'uuu', selected: true }] }, - { name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] }, - ]; - expect(buildSelector(labels)).toEqual('{"utf8.metric","utf8.label"="uuu",bar="baz"}'); - }); - - it('metric selector with non-utf8 metric with utf8/non-utf8 labels', () => { - const labels: SelectableLabel[] = [ - { name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }, - { name: 'utf8.label', selected: true, values: [{ name: 'uuu', selected: true }] }, - { name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] }, - ]; - expect(buildSelector(labels)).toEqual('foo{"utf8.label"="uuu",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); - }); -}); +import { UnthemedPrometheusMetricsBrowser } from './PrometheusMetricsBrowser'; +import { BrowserProps } from './types'; describe('PrometheusMetricsBrowser', () => { const setupProps = (): BrowserProps => { diff --git a/packages/grafana-prometheus/src/components/metrics-browser/PrometheusMetricsBrowser.tsx b/packages/grafana-prometheus/src/components/metrics-browser/PrometheusMetricsBrowser.tsx new file mode 100644 index 00000000000..a9edd529d3e --- /dev/null +++ b/packages/grafana-prometheus/src/components/metrics-browser/PrometheusMetricsBrowser.tsx @@ -0,0 +1,373 @@ +// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx +import { ChangeEvent, Component, MouseEvent } from 'react'; + +import { getDefaultTimeRange } from '@grafana/data'; +import { LoadingPlaceholder, Stack, withTheme2 } from '@grafana/ui'; + +import { LabelSelector } from './LabelSelector'; +import { MetricSelector } from './MetricSelector'; +import { SelectorActions } from './SelectorActions'; +import { ValueSelector } from './ValueSelector'; +import { buildSelector, facetLabels } from './selectorBuilder'; +import { getStyles } from './styles'; +import { + BrowserProps, + BrowserState, + DEFAULT_SERIES_LIMIT, + EMPTY_SELECTOR, + FacettableValue, + LAST_USED_LABELS_KEY, + METRIC_LABEL, + SelectableLabel, +} from './types'; + +export class UnthemedPrometheusMetricsBrowser extends Component { + state: BrowserState = { + labels: [], + 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 }); + }; + + onChangeSeriesLimit = (event: ChangeEvent) => { + this.setState({ seriesLimit: event.target.value.trim() }); + }; + + 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}[$__rate_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: '', + }; + }); + localStorage.removeItem(LAST_USED_LABELS_KEY); + // Get metrics + this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR); + }; + + onClickLabel = (name: string, value: string | undefined, event: 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: 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: 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[] = JSON.parse(localStorage.getItem(LAST_USED_LABELS_KEY) ?? `[]`) ?? []; + languageProvider.start(this.props.timeRange).then(() => { + let rawLabels: string[] = languageProvider.getLabelKeys(); + // Get metrics + this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR); + // 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, EMPTY_SELECTOR); + } + }); + }); + }); + } + } + + 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); + localStorage.setItem(LAST_USED_LABELS_KEY, JSON.stringify(selectedLabels)); + if (label.selected) { + // Refetch values for newly selected label... + if (!label.values) { + this.fetchValues(name, buildSelector(this.state.labels)); + } + } 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, selector) + ); + }); + } else { + // Do facetting + this.fetchSeries(selector, lastFacetted); + } + }; + + async fetchValues(name: string, selector: string) { + const { languageProvider } = this.props; + this.updateLabelState(name, { loading: true }, `Fetching values for ${name}`); + try { + let rawValues = await languageProvider.getLabelValues(this.props.timeRange ?? getDefaultTimeRange(), name); + // If selector changed, clear loading state and discard result by returning early + if (selector !== buildSelector(this.state.labels)) { + this.updateLabelState(name, { loading: false }); + return; + } + const values: FacettableValue[] = []; + const { metricsMetadata } = languageProvider; + for (const labelValue of rawValues) { + const value: FacettableValue = { name: labelValue }; + // Adding type/help text to metrics + if (name === METRIC_LABEL && metricsMetadata) { + const meta = metricsMetadata[labelValue]; + if (meta) { + value.details = `(${meta.type}) ${meta.help}`; + } + } + values.push(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( + this.props.timeRange ?? getDefaultTimeRange(), + selector, + true, + this.state.seriesLimit + ); + // If selector changed, clear loading state and discard result by returning early + if (selector !== buildSelector(this.state.labels)) { + if (lastFacetted) { + this.updateLabelState(lastFacetted, { loading: false }); + } + return; + } + if (Object.keys(possibleLabels).length === 0) { + this.setState({ error: `Empty results, no matching label 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(this.props.timeRange ?? getDefaultTimeRange(), selector); + this.setState({ validationStatus: `Selector is valid (${streams.length} series 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) { + 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) { + 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) { + 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 ( +
+ + +
+ + + +
+
+ + +
+ ); + } +} + +export const PrometheusMetricsBrowser = withTheme2(UnthemedPrometheusMetricsBrowser); diff --git a/packages/grafana-prometheus/src/components/metrics-browser/SelectorActions.tsx b/packages/grafana-prometheus/src/components/metrics-browser/SelectorActions.tsx new file mode 100644 index 00000000000..2ce44e876e8 --- /dev/null +++ b/packages/grafana-prometheus/src/components/metrics-browser/SelectorActions.tsx @@ -0,0 +1,79 @@ +import { cx } from '@emotion/css'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Button, Label, Stack } from '@grafana/ui'; + +interface SelectorActionsProps { + selector: string; + validationStatus: string; + status: string; + error: string; + empty: boolean; + onClickRunQuery: () => void; + onClickRunRateQuery: () => void; + onClickValidate: () => void; + onClickClear: () => void; + styles: Record; +} + +export function SelectorActions({ + selector, + validationStatus, + status, + error, + empty, + onClickRunQuery, + onClickRunRateQuery, + onClickValidate, + onClickClear, + styles, +}: SelectorActionsProps) { + return ( +
+ +
+ {selector} +
+ {validationStatus &&
{validationStatus}
} + + + + + +
+ {error || status} +
+
+
+ ); +} diff --git a/packages/grafana-prometheus/src/components/metrics-browser/ValueSelector.tsx b/packages/grafana-prometheus/src/components/metrics-browser/ValueSelector.tsx new file mode 100644 index 00000000000..cdd2958649f --- /dev/null +++ b/packages/grafana-prometheus/src/components/metrics-browser/ValueSelector.tsx @@ -0,0 +1,84 @@ +import { ChangeEvent, MouseEvent } from 'react'; +import { FixedSizeList } from 'react-window'; + +import { selectors } from '@grafana/e2e-selectors'; +import { Input, Label, BrowserLabel as PromLabel } from '@grafana/ui'; + +import { SelectableLabel, LIST_ITEM_SIZE } from './types'; + +interface ValueSelectorProps { + selectedLabels: SelectableLabel[]; + valueSearchTerm: string; + onChangeValueSearch: (event: ChangeEvent) => void; + onClickValue: (name: string, value: string | undefined, event: MouseEvent) => void; + onClickLabel: (name: string, value: string | undefined, event: MouseEvent) => void; + styles: Record; +} + +export function ValueSelector({ + selectedLabels, + valueSearchTerm, + onChangeValueSearch, + onClickValue, + onClickLabel, + styles, +}: ValueSelectorProps) { + return ( +
+ +
+ +
+
+ {selectedLabels.map((label) => ( +
+
+
+ label.values![i].name} + width={200} + className={styles.valueList} + > + {({ index, style }) => { + const value = label.values?.[index]; + if (!value) { + return null; + } + return ( +
+ +
+ ); + }} +
+
+ ))} +
+
+ ); +} diff --git a/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.test.ts b/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.test.ts new file mode 100644 index 00000000000..6123e4bc56b --- /dev/null +++ b/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.test.ts @@ -0,0 +1,441 @@ +import { buildSelector, facetLabels } from './selectorBuilder'; +import { METRIC_LABEL, SelectableLabel } from './types'; + +describe('selectorBuilder', () => { + 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('utf8 support', () => { + it('metric selector with utf8 metric', () => { + const labels: SelectableLabel[] = [ + { name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] }, + ]; + expect(buildSelector(labels)).toEqual('{"utf8.metric"}'); + }); + + it('metric selector with utf8 labels', () => { + const labels: SelectableLabel[] = [ + { name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }, + { name: 'utf8.label', selected: true, values: [{ name: 'baz', selected: true }] }, + ]; + expect(buildSelector(labels)).toEqual('foo{"utf8.label"="baz"}'); + }); + + it('metric selector with utf8 labels and metrics', () => { + const labels: SelectableLabel[] = [ + { name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] }, + { name: 'utf8.label', selected: true, values: [{ name: 'baz', selected: true }] }, + ]; + expect(buildSelector(labels)).toEqual('{"utf8.metric","utf8.label"="baz"}'); + }); + + it('metric selector with utf8 metric and with utf8/non-utf8 labels', () => { + const labels: SelectableLabel[] = [ + { name: '__name__', selected: true, values: [{ name: 'utf8.metric', selected: true }] }, + { name: 'utf8.label', selected: true, values: [{ name: 'uuu', selected: true }] }, + { name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] }, + ]; + expect(buildSelector(labels)).toEqual('{"utf8.metric","utf8.label"="uuu",bar="baz"}'); + }); + + it('metric selector with non-utf8 metric with utf8/non-utf8 labels', () => { + const labels: SelectableLabel[] = [ + { name: '__name__', selected: true, values: [{ name: 'foo', selected: true }] }, + { name: 'utf8.label', selected: true, values: [{ name: 'uuu', selected: true }] }, + { name: 'bar', selected: true, values: [{ name: 'baz', selected: true }] }, + ]; + expect(buildSelector(labels)).toEqual('foo{"utf8.label"="uuu",bar="baz"}'); + }); + }); + + it('should return empty selector when no labels are selected', () => { + const labels: SelectableLabel[] = []; + expect(buildSelector(labels)).toBe('{}'); + }); + + it('should return empty selector when no values are selected', () => { + const labels: SelectableLabel[] = [ + { + name: 'job', + selected: true, + values: [ + { name: 'prometheus', selected: false }, + { name: 'node_exporter', selected: false }, + ], + }, + ]; + expect(buildSelector(labels)).toBe('{}'); + }); + + it('should build selector with a single metric', () => { + const labels: SelectableLabel[] = [ + { + name: METRIC_LABEL, + values: [{ name: 'http_requests_total', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe('http_requests_total{}'); + }); + + it('should build selector with a single label', () => { + const labels: SelectableLabel[] = [ + { + name: 'job', + selected: true, + values: [{ name: 'prometheus', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe('{job="prometheus"}'); + }); + + it('should build selector with a metric and a label', () => { + const labels: SelectableLabel[] = [ + { + name: METRIC_LABEL, + values: [{ name: 'http_requests_total', selected: true }], + }, + { + name: 'job', + selected: true, + values: [{ name: 'prometheus', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe('http_requests_total{job="prometheus"}'); + }); + + it('should build selector with multiple label values using regex', () => { + const labels: SelectableLabel[] = [ + { + name: 'job', + selected: true, + values: [ + { name: 'prometheus', selected: true }, + { name: 'node_exporter', selected: true }, + ], + }, + ]; + expect(buildSelector(labels)).toBe('{job=~"prometheus|node_exporter"}'); + }); + + it('should build selector with multiple labels', () => { + const labels: SelectableLabel[] = [ + { + name: METRIC_LABEL, + values: [{ name: 'http_requests_total', selected: true }], + }, + { + name: 'job', + selected: true, + values: [{ name: 'prometheus', selected: true }], + }, + { + name: 'instance', + selected: true, + values: [{ name: 'localhost:9090', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe('http_requests_total{job="prometheus",instance="localhost:9090"}'); + }); + + it('should build selector with a mix of single and multiple values', () => { + const labels: SelectableLabel[] = [ + { + name: METRIC_LABEL, + values: [{ name: 'http_requests_total', selected: true }], + }, + { + name: 'job', + selected: true, + values: [ + { name: 'prometheus', selected: true }, + { name: 'node_exporter', selected: true }, + ], + }, + { + name: 'instance', + selected: true, + values: [{ name: 'localhost:9090', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe( + 'http_requests_total{job=~"prometheus|node_exporter",instance="localhost:9090"}' + ); + }); + + it('should handle non-legacy metric names with quotes', () => { + const labels: SelectableLabel[] = [ + { + name: METRIC_LABEL, + values: [{ name: 'metric-with-dashes', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe('{"metric-with-dashes"}'); + }); + + it('should handle non-legacy metric names with labels', () => { + const labels: SelectableLabel[] = [ + { + name: METRIC_LABEL, + values: [{ name: 'metric-with-dashes', selected: true }], + }, + { + name: 'job', + selected: true, + values: [{ name: 'prometheus', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe('{"metric-with-dashes",job="prometheus"}'); + }); + + it('should ignore unselected labels', () => { + const labels: SelectableLabel[] = [ + { + name: METRIC_LABEL, + values: [{ name: 'http_requests_total', selected: true }], + }, + { + name: 'job', + selected: true, + values: [{ name: 'prometheus', selected: true }], + }, + { + name: 'instance', + selected: false, + values: [{ name: 'localhost:9090', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe('http_requests_total{job="prometheus"}'); + }); + + it('should ignore labels with no selected values', () => { + const labels: SelectableLabel[] = [ + { + name: METRIC_LABEL, + values: [{ name: 'http_requests_total', selected: true }], + }, + { + name: 'job', + selected: true, + values: [{ name: 'prometheus', selected: false }], + }, + ]; + expect(buildSelector(labels)).toBe('http_requests_total{}'); + }); + + it('should handle special characters in label values', () => { + const labels: SelectableLabel[] = [ + { + name: 'job', + selected: true, + values: [{ name: 'value with spaces', selected: true }], + }, + { + name: 'instance', + selected: true, + values: [{ name: 'host:with:colons', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe('{job="value with spaces",instance="host:with:colons"}'); + }); + + it('should handle empty string values', () => { + const labels: SelectableLabel[] = [ + { + name: 'job', + selected: true, + values: [{ name: '', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe('{job=""}'); + }); + + it('should handle multiple metrics with some unselected', () => { + const labels: SelectableLabel[] = [ + { + name: METRIC_LABEL, + values: [ + { name: 'metric1', selected: true }, + { name: 'metric2', selected: true }, + { name: 'metric3', selected: false }, + ], + }, + ]; + expect(buildSelector(labels)).toBe('{__name__=~"metric1|metric2"}'); + }); + + it('should handle Unicode characters in label names and values', () => { + const labels: SelectableLabel[] = [ + { + name: 'région', + selected: true, + values: [{ name: 'París', selected: true }], + }, + { + name: '环境', + selected: true, + values: [{ name: '测试', selected: true }], + }, + ]; + expect(buildSelector(labels)).toBe('{"région"="París","环境"="测试"}'); + }); + }); + + 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); + }); + + it('should preserve loading state during facetting', () => { + const labels: SelectableLabel[] = [ + { name: 'job', selected: true, loading: true }, + { name: 'instance', selected: false }, + ]; + + const possibleLabels = { + job: ['prometheus'], + }; + + const result = facetLabels(labels, possibleLabels); + expect(result[0].loading).toBe(false); // Loading should be reset after facetting + }); + + it('should handle multiple selected values preservation', () => { + const labels: SelectableLabel[] = [ + { + name: 'job', + selected: true, + values: [ + { name: 'prometheus', selected: true }, + { name: 'grafana', selected: true }, + ], + }, + ]; + + const possibleLabels = { + job: ['prometheus', 'grafana', 'loki'], + }; + + const result = facetLabels(labels, possibleLabels); + const selectedValues = result[0].values?.filter((v) => v.selected).map((v) => v.name); + expect(selectedValues).toEqual(['prometheus', 'grafana']); + }); + + it('should handle mixed selected/unselected values during facetting', () => { + const labels: SelectableLabel[] = [ + { + name: 'job', + selected: true, + values: [ + { name: 'prometheus', selected: true }, + { name: 'grafana', selected: false }, + { name: 'loki', selected: true }, + ], + }, + ]; + + const possibleLabels = { + job: ['prometheus', 'grafana', 'loki', 'tempo'], + }; + + const result = facetLabels(labels, possibleLabels); + expect(result[0].values?.filter((v) => v.selected).map((v) => v.name)).toEqual(['prometheus', 'loki']); + expect(result[0].values?.length).toBe(4); // Should include all possible values + }); + + it('should handle zero facets case', () => { + const labels: SelectableLabel[] = [ + { + name: 'job', + selected: true, + values: [{ name: 'prometheus', selected: true }], + }, + ]; + + const possibleLabels = { + job: [], + }; + + const result = facetLabels(labels, possibleLabels); + expect(result[0].facets).toBe(0); + expect(result[0].hidden).toBe(false); // Should still be visible as it's in possibleLabels + expect(result[0].values).toEqual([]); // Should have empty values array + }); + }); +}); diff --git a/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.ts b/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.ts new file mode 100644 index 00000000000..6025e522cf5 --- /dev/null +++ b/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.ts @@ -0,0 +1,74 @@ +import { escapeLabelValueInExactSelector, escapeLabelValueInRegexSelector } from '../../language_utils'; +import { isValidLegacyName, utf8Support } from '../../utf8_support'; + +import { FacettableValue, METRIC_LABEL, SelectableLabel } from './types'; + +export function buildSelector(labels: SelectableLabel[]): string { + let singleMetric = ''; + const selectedLabels: string[] = []; + 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( + `${utf8Support(label.name)}=~"${selectedValues.map(escapeLabelValueInRegexSelector).join('|')}"` + ); + } else if (selectedValues.length === 1) { + if (label.name === METRIC_LABEL) { + singleMetric = selectedValues[0]; + } else { + selectedLabels.push(`${utf8Support(label.name)}="${escapeLabelValueInExactSelector(selectedValues[0])}"`); + } + } + } + } + + const selectorParts: string[] = []; + const isLegacyName = singleMetric === '' || isValidLegacyName(singleMetric); + + if (isLegacyName) { + selectorParts.push(singleMetric, '{'); + } else { + selectorParts.push('{', `"${singleMetric}"`); + if (selectedLabels.length > 0) { + selectorParts.push(','); + } + } + + selectorParts.push(selectedLabels.join(','), '}'); + return selectorParts.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 }; + }); +} diff --git a/packages/grafana-prometheus/src/components/metrics-browser/styles.ts b/packages/grafana-prometheus/src/components/metrics-browser/styles.ts new file mode 100644 index 00000000000..975f62b1512 --- /dev/null +++ b/packages/grafana-prometheus/src/components/metrics-browser/styles.ts @@ -0,0 +1,78 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { stylesFactory } from '@grafana/ui'; + +export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ + wrapper: css({ + backgroundColor: theme.colors.background.secondary, + padding: theme.spacing(1), + width: '100%', + }), + list: css({ + marginTop: theme.spacing(1), + display: 'flex', + flexWrap: 'wrap', + maxHeight: '200px', + overflow: 'auto', + alignContent: 'flex-start', + }), + section: css({ + '& + &': { + margin: `${theme.spacing(2)} 0`, + }, + position: 'relative', + }), + selector: css({ + fontFamily: theme.typography.fontFamilyMonospace, + marginBottom: theme.spacing(1), + }), + status: css({ + padding: theme.spacing(0.5), + color: theme.colors.text.secondary, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + /* using absolute positioning because flex interferes with ellipsis */ + position: 'absolute', + width: '50%', + right: 0, + textAlign: 'right', + opacity: 0, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'opacity 100ms linear', + }, + }), + statusShowing: css({ + opacity: 1, + }), + error: css({ + color: theme.colors.error.main, + }), + valueList: css({ + marginRight: theme.spacing(1), + resize: 'horizontal', + }), + valueListWrapper: css({ + borderLeft: `1px solid ${theme.colors.border.medium}`, + margin: `${theme.spacing(1)} 0`, + padding: `${theme.spacing(1)} 0 ${theme.spacing(1)} ${theme.spacing(1)}`, + }), + valueListArea: css({ + display: 'flex', + flexWrap: 'wrap', + marginTop: theme.spacing(1), + }), + valueTitle: css({ + marginLeft: `-${theme.spacing(0.5)}`, + marginBottom: theme.spacing(1), + }), + validationStatus: css({ + padding: theme.spacing(0.5), + marginBottom: theme.spacing(1), + color: theme.colors.text.maxContrast, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }), +})); diff --git a/packages/grafana-prometheus/src/components/metrics-browser/types.ts b/packages/grafana-prometheus/src/components/metrics-browser/types.ts new file mode 100644 index 00000000000..1c3e8ec59fd --- /dev/null +++ b/packages/grafana-prometheus/src/components/metrics-browser/types.ts @@ -0,0 +1,45 @@ +import { GrafanaTheme2, TimeRange } from '@grafana/data'; + +import PromQlLanguageProvider from '../../language_provider'; + +export interface FacettableValue { + name: string; + selected?: boolean; + details?: string; +} + +export interface SelectableLabel { + name: string; + selected?: boolean; + loading?: boolean; + values?: FacettableValue[]; + hidden?: boolean; + facets?: number; +} + +export interface BrowserProps { + languageProvider: PromQlLanguageProvider; + onChange: (selector: string) => void; + theme: GrafanaTheme2; + autoSelect?: number; + hide?: () => void; + timeRange?: TimeRange; +} + +export interface BrowserState { + labels: SelectableLabel[]; + labelSearchTerm: string; + metricSearchTerm: string; + status: string; + error: string; + validationStatus: string; + valueSearchTerm: string; + seriesLimit?: string; +} + +export const DEFAULT_SERIES_LIMIT = '40000'; +export const REMOVE_SERIES_LIMIT = 'none'; +export const EMPTY_SELECTOR = '{}'; +export const METRIC_LABEL = '__name__'; +export const LIST_ITEM_SIZE = 25; +export const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels'; diff --git a/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts b/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts index 84ef8ef7304..1237d2c79b4 100644 --- a/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts +++ b/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts @@ -69,7 +69,9 @@ describe('usePromQueryFieldEffects', () => { }); it('should call refreshMetrics and refreshHint on initial render', async () => { - renderHook(() => usePromQueryFieldEffects(mockRange, [], refreshMetricsMock, refreshHintMock)); + renderHook(() => + usePromQueryFieldEffects(mockLanguageProvider, mockRange, [], refreshMetricsMock, refreshHintMock) + ); expect(refreshMetricsMock).toHaveBeenCalledTimes(1); expect(refreshHintMock).toHaveBeenCalledTimes(2); @@ -77,7 +79,14 @@ describe('usePromQueryFieldEffects', () => { it('should call refreshMetrics when the time range changes', async () => { const { rerender } = renderHook( - (props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock), + (props: TestProps) => + usePromQueryFieldEffects( + props.languageProvider, + props.range, + props.series, + refreshMetricsMock, + refreshHintMock + ), { initialProps: { languageProvider: mockLanguageProvider, @@ -102,7 +111,14 @@ describe('usePromQueryFieldEffects', () => { it('should not call refreshMetrics when the time range is the same', () => { const { rerender } = renderHook( - (props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock), + (props: TestProps) => + usePromQueryFieldEffects( + props.languageProvider, + props.range, + props.series, + refreshMetricsMock, + refreshHintMock + ), { initialProps: { languageProvider: mockLanguageProvider, @@ -129,7 +145,14 @@ describe('usePromQueryFieldEffects', () => { it('should call refreshHint when series changes', () => { const mockSeries = [{ name: 'new series', fields: [], length: 0 }] as DataFrame[]; const { rerender } = renderHook( - (props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock), + (props: TestProps) => + usePromQueryFieldEffects( + props.languageProvider, + props.range, + props.series, + refreshMetricsMock, + refreshHintMock + ), { initialProps: { languageProvider: mockLanguageProvider, @@ -157,7 +180,14 @@ describe('usePromQueryFieldEffects', () => { it('should not call refreshHint when series is the same', () => { const series = [] as DataFrame[]; const { rerender } = renderHook( - (props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock), + (props: TestProps) => + usePromQueryFieldEffects( + props.languageProvider, + props.range, + props.series, + refreshMetricsMock, + refreshHintMock + ), { initialProps: { languageProvider: mockLanguageProvider, diff --git a/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts b/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts index 643b0fe314f..b8ec88f413d 100644 --- a/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts +++ b/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts @@ -2,11 +2,13 @@ import { MutableRefObject, useEffect, useRef } from 'react'; import { DataFrame, DateTime, TimeRange } from '@grafana/data'; +import PromQlLanguageProvider from '../language_provider'; import { roundMsToMin } from '../language_utils'; import { CancelablePromise } from './cancelable-promise'; export function usePromQueryFieldEffects( + languageProvider: PromQlLanguageProvider, range: TimeRange | undefined, series: DataFrame[] | undefined, refreshMetrics: (languageProviderInitRef: MutableRefObject | null>) => Promise, @@ -17,7 +19,9 @@ export function usePromQueryFieldEffects( // Effect for initial load useEffect(() => { - refreshMetrics(languageProviderInitRef); + if (languageProvider) { + refreshMetrics(languageProviderInitRef); + } refreshHint(); return () => { diff --git a/packages/grafana-prometheus/src/index.ts b/packages/grafana-prometheus/src/index.ts index 80fa0e5130d..23161591542 100644 --- a/packages/grafana-prometheus/src/index.ts +++ b/packages/grafana-prometheus/src/index.ts @@ -9,7 +9,7 @@ export { PromQueryEditorByApp } from './components/PromQueryEditorByApp'; export { MonacoQueryFieldLazy } from './components/monaco-query-field/MonacoQueryFieldLazy'; export { AnnotationQueryEditor } from './components/AnnotationQueryEditor'; export { PromCheatSheet } from './components/PromCheatSheet'; -export { PrometheusMetricsBrowser } from './components/PrometheusMetricsBrowser'; +export { PrometheusMetricsBrowser } from './components/metrics-browser/PrometheusMetricsBrowser'; export { PromExemplarField } from './components/PromExemplarField'; export { PromExploreExtraField } from './components/PromExploreExtraField'; export { PromQueryEditorForAlerting } from './components/PromQueryEditorForAlerting'; diff --git a/packages/grafana-prometheus/src/language_provider.test.ts b/packages/grafana-prometheus/src/language_provider.test.ts index e91845d1f00..fa0694d73f8 100644 --- a/packages/grafana-prometheus/src/language_provider.test.ts +++ b/packages/grafana-prometheus/src/language_provider.test.ts @@ -1,7 +1,7 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/language_provider.test.ts import { AbstractLabelOperator, dateTime, TimeRange } from '@grafana/data'; -import { DEFAULT_SERIES_LIMIT } from './components/PrometheusMetricsBrowser'; +import { DEFAULT_SERIES_LIMIT } from './components/metrics-browser/types'; import { Label } from './components/monaco-query-field/monaco-completion-provider/situation'; import { PrometheusDatasource } from './datasource'; import LanguageProvider, { removeQuotesIfExist } from './language_provider'; diff --git a/packages/grafana-prometheus/src/language_provider.ts b/packages/grafana-prometheus/src/language_provider.ts index 04a9f7e6e7b..9f0bbf259c9 100644 --- a/packages/grafana-prometheus/src/language_provider.ts +++ b/packages/grafana-prometheus/src/language_provider.ts @@ -16,7 +16,7 @@ import { } from '@grafana/data'; import { BackendSrvRequest } from '@grafana/runtime'; -import { DEFAULT_SERIES_LIMIT, REMOVE_SERIES_LIMIT } from './components/PrometheusMetricsBrowser'; +import { DEFAULT_SERIES_LIMIT, REMOVE_SERIES_LIMIT } from './components/metrics-browser/types'; import { Label } from './components/monaco-query-field/monaco-completion-provider/situation'; import { PrometheusDatasource } from './datasource'; import {