setValueSearchTerm(e.currentTarget.value)}
aria-label="Filter expression for label values"
value={valueSearchTerm}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelValuesFilter}
/>
- {selectedLabels.map((label) => (
-
-
-
+ {Object.entries(labelValues).map(([lk, lv]) => {
+ if (!lk || !lv) {
+ console.error('label values are empty:', { lk, lv });
+ return null;
+ }
+ return (
+
+
+
lv[i]}
+ width={200}
+ className={styles.valueList}
+ >
+ {({ index, style }) => {
+ const value = lv[index];
+ const isSelected = selectedLabelValues[lk]?.includes(value);
+ return (
+
+
onLabelValueClick(lk, name, !isSelected)}
+ searchTerm={valueSearchTerm}
+ />
+
+ );
+ }}
+
-
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
index 6123e4bc56b..5e8446d13ca 100644
--- a/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.test.ts
+++ b/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.test.ts
@@ -1,441 +1,106 @@
-import { buildSelector, facetLabels } from './selectorBuilder';
-import { METRIC_LABEL, SelectableLabel } from './types';
+import { buildSelector } from './selectorBuilder';
describe('selectorBuilder', () => {
- describe('buildSelector', () => {
+ describe('buildSelector()', () => {
it('returns an empty selector for no labels', () => {
- expect(buildSelector([])).toEqual('{}');
+ 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('{}');
+ expect(buildSelector('', {})).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('{}');
+ expect(buildSelector('', {})).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"}');
+ expect(buildSelector('', { foo: ['bar'] })).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"}');
+ expect(buildSelector('foo', {})).toEqual('foo{}');
});
+
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"}');
+ expect(buildSelector('foo', { bar: ['baz'] })).toEqual('foo{bar="baz"}');
+ });
+
+ it('skips labels with empty value arrays', () => {
+ expect(buildSelector('metric', { emptyLabel: [], validLabel: ['value'] })).toEqual('metric{validLabel="value"}');
+ });
+
+ it('handles multiple values for a label using regex matcher', () => {
+ expect(buildSelector('', { multi: ['val1', 'val2', 'val3'] })).toEqual('{multi=~"val1|val2|val3"}');
+ });
+
+ it('properly escapes special characters in regex values', () => {
+ expect(buildSelector('', { special: ['val*', 'val.', 'val+'] })).toEqual(
+ '{special=~"val\\\\*|val\\\\.|val\\\\+"}'
+ );
+ });
+
+ it('properly escapes double quotes in exact matcher', () => {
+ expect(buildSelector('', { quoted: ['value"with"quotes'] })).toEqual('{quoted="value\\"with\\"quotes"}');
+ });
+
+ it('properly handles newlines in values', () => {
+ expect(buildSelector('', { newline: ['value\nwith\nnewlines'] })).toEqual('{newline="value\\nwith\\nnewlines"}');
+ });
+
+ it('combines multiple labels properly', () => {
+ expect(
+ buildSelector('', {
+ label1: ['value1'],
+ label2: ['value2'],
+ label3: ['value3'],
+ })
+ ).toEqual('{label1="value1",label2="value2",label3="value3"}');
+ });
+
+ it('combines single and multi-value labels correctly', () => {
+ expect(
+ buildSelector('', {
+ single: ['value'],
+ multi: ['val1', 'val2'],
+ })
+ ).toEqual('{single="value",multi=~"val1|val2"}');
});
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"}');
+ expect(buildSelector('utf8.metric', {})).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"}');
+ expect(buildSelector('foo', { 'utf8.label': ['baz'] })).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"}');
+ expect(buildSelector('utf8.metric', { 'utf8.label': ['baz'] })).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"}');
+ expect(
+ buildSelector('utf8.metric', {
+ 'utf8.label': ['uuu'],
+ bar: ['baz'],
+ })
+ ).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"}');
+ expect(
+ buildSelector('foo', {
+ 'utf8.label': ['uuu'],
+ bar: ['baz'],
+ })
+ ).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
+ it('handles utf8 characters in label values', () => {
+ expect(buildSelector('', { label: ['值', '😀', 'ä½ å¥½'] })).toEqual('{label=~"值|😀|ä½ å¥½"}');
+ });
});
});
});
diff --git a/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.ts b/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.ts
index 6025e522cf5..c6ca8bf0e5a 100644
--- a/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.ts
+++ b/packages/grafana-prometheus/src/components/metrics-browser/selectorBuilder.ts
@@ -1,74 +1,47 @@
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])}"`);
- }
- }
- }
+/**
+ * Builds a Prometheus selector string from a metric name and label values
+ * @param selectedMetric - The metric name, can be empty
+ * @param selectedLabelValues - Record of label names to their selected values
+ * @returns A properly formatted Prometheus selector string
+ */
+export function buildSelector(selectedMetric: string, selectedLabelValues: Record
): string {
+ // Handle empty case
+ if (selectedMetric === '' && Object.keys(selectedLabelValues).length === 0) {
+ return '{}';
}
+ // Build all label selectors
const selectorParts: string[] = [];
- const isLegacyName = singleMetric === '' || isValidLegacyName(singleMetric);
- if (isLegacyName) {
- selectorParts.push(singleMetric, '{');
- } else {
- selectorParts.push('{', `"${singleMetric}"`);
- if (selectedLabels.length > 0) {
- selectorParts.push(',');
+ // Process label selectors
+ for (const [key, values] of Object.entries(selectedLabelValues)) {
+ // Skip empty value arrays
+ if (values.length === 0) {
+ continue;
+ }
+
+ // Use regex matcher for multiple values
+ if (values.length > 1) {
+ selectorParts.push(`${utf8Support(key)}=~"${values.map(escapeLabelValueInRegexSelector).join('|')}"`);
+ } else {
+ // Use exact matcher for single value
+ selectorParts.push(`${utf8Support(key)}="${escapeLabelValueInExactSelector(values[0])}"`);
}
}
- selectorParts.push(selectedLabels.join(','), '}');
- return selectorParts.join('');
-}
+ // Handle metric name cases
+ if (selectedMetric === '') {
+ 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,
- };
- }
+ if (isValidLegacyName(selectedMetric)) {
+ return `${selectedMetric}{${selectorParts.join(',')}}`;
+ }
- // Label is facetted out, hide all values
- return { ...label, loading: false, hidden: !possibleValues, values: undefined, facets: 0 };
- });
+ // Add quoted metric as another selector when it's not a valid legacy name
+ selectorParts.unshift(utf8Support(selectedMetric));
+ return `{${selectorParts.join(',')}}`;
}
diff --git a/packages/grafana-prometheus/src/components/metrics-browser/styles.ts b/packages/grafana-prometheus/src/components/metrics-browser/styles.ts
index 975f62b1512..4ef89b716b6 100644
--- a/packages/grafana-prometheus/src/components/metrics-browser/styles.ts
+++ b/packages/grafana-prometheus/src/components/metrics-browser/styles.ts
@@ -1,14 +1,40 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
-import { stylesFactory } from '@grafana/ui';
-export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
+export const getStylesMetricsBrowser = (theme: GrafanaTheme2) => ({
wrapper: css({
backgroundColor: theme.colors.background.secondary,
padding: theme.spacing(1),
width: '100%',
}),
+});
+
+export const getStylesMetricSelector = (theme: GrafanaTheme2) => ({
+ section: css({
+ '& + &': {
+ margin: `${theme.spacing(2)} 0`,
+ },
+ position: 'relative',
+ }),
+ 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)}`,
+ }),
+ valueList: css({
+ marginRight: theme.spacing(1),
+ resize: 'horizontal',
+ }),
+});
+
+export const getStylesLabelSelector = (theme: GrafanaTheme2) => ({
+ section: css({
+ '& + &': {
+ margin: `${theme.spacing(2)} 0`,
+ },
+ position: 'relative',
+ }),
list: css({
marginTop: theme.spacing(1),
display: 'flex',
@@ -17,6 +43,36 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
overflow: 'auto',
alignContent: 'flex-start',
}),
+});
+
+export const getStylesValueSelector = (theme: GrafanaTheme2) => ({
+ section: css({
+ '& + &': {
+ margin: `${theme.spacing(2)} 0`,
+ },
+ position: 'relative',
+ }),
+ valueListArea: css({
+ display: 'flex',
+ flexWrap: 'wrap',
+ marginTop: theme.spacing(1),
+ }),
+ valueTitle: css({
+ marginLeft: `-${theme.spacing(0.5)}`,
+ marginBottom: theme.spacing(1),
+ }),
+ 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)}`,
+ }),
+ valueList: css({
+ marginRight: theme.spacing(1),
+ resize: 'horizontal',
+ }),
+});
+
+export const getStylesSelectorActions = (theme: GrafanaTheme2) => ({
section: css({
'& + &': {
margin: `${theme.spacing(2)} 0`,
@@ -33,9 +89,7 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
- /* using absolute positioning because flex interferes with ellipsis */
- position: 'absolute',
- width: '50%',
+ width: '100%',
right: 0,
textAlign: 'right',
opacity: 0,
@@ -49,24 +103,6 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
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),
@@ -75,4 +111,4 @@ export const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
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
index 1c3e8ec59fd..905845b6ca6 100644
--- a/packages/grafana-prometheus/src/components/metrics-browser/types.ts
+++ b/packages/grafana-prometheus/src/components/metrics-browser/types.ts
@@ -1,42 +1,8 @@
-import { GrafanaTheme2, TimeRange } from '@grafana/data';
-
-import PromQlLanguageProvider from '../../language_provider';
-
-export interface FacettableValue {
+export interface Metric {
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 = '{}';
diff --git a/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts
new file mode 100644
index 00000000000..5d33260ebb9
--- /dev/null
+++ b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.test.ts
@@ -0,0 +1,904 @@
+import { renderHook, waitFor, act } from '@testing-library/react';
+
+import { TimeRange } from '@grafana/data';
+
+import PromQlLanguageProvider from '../../language_provider';
+import { getMockTimeRange } from '../../test/__mocks__/datasource';
+
+import { buildSelector } from './selectorBuilder';
+import { LAST_USED_LABELS_KEY, DEFAULT_SERIES_LIMIT, EMPTY_SELECTOR, METRIC_LABEL } from './types';
+import { useMetricsLabelsValues } from './useMetricsLabelsValues';
+
+// Mock buildSelector
+jest.mock('./selectorBuilder', () => ({
+ buildSelector: jest.fn().mockImplementation(() => '{}'),
+ EMPTY_SELECTOR: '{}',
+}));
+
+// Mock local storage
+const localStorageMock = (() => {
+ let store: Record = {};
+ return {
+ getItem: jest.fn((key: string) => store[key] || null),
+ setItem: jest.fn((key: string, value: string) => {
+ store[key] = value;
+ }),
+ clear: jest.fn(() => {
+ store = {};
+ }),
+ };
+})();
+Object.defineProperty(window, 'localStorage', { value: localStorageMock });
+
+// Mock console methods to suppress logging during tests
+const originalConsoleLog = console.log;
+const originalConsoleError = console.error;
+
+describe('useMetricsLabelsValues', () => {
+ // Mock dependencies
+ const mockTimeRange: TimeRange = getMockTimeRange();
+ const mockLanguageProvider = {
+ metrics: ['metric1', 'metric2', 'metric3'],
+ labelKeys: ['__name__', 'instance', 'job', 'service'],
+ metricsMetadata: {
+ metric1: { type: 'counter', help: 'Test metric 1' },
+ metric2: { type: 'gauge', help: 'Test metric 2' },
+ },
+ fetchLabelValues: jest.fn(),
+ fetchLabels: jest.fn(),
+ fetchSeriesValuesWithMatch: jest.fn(),
+ fetchSeriesLabelsMatch: jest.fn(),
+ } as unknown as PromQlLanguageProvider;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ localStorageMock.clear();
+ (buildSelector as jest.Mock).mockClear();
+
+ // Suppress console output during tests
+ console.log = jest.fn();
+ console.error = jest.fn();
+
+ // Mock fetchLabelValues (for metrics)
+ (mockLanguageProvider.fetchLabelValues as jest.Mock).mockResolvedValue(['metric1', 'metric2', 'metric3']);
+
+ // Mock fetchLabels for label keys
+ (mockLanguageProvider.fetchLabels as jest.Mock).mockResolvedValue(['__name__', 'instance', 'job', 'service']);
+
+ // Default implementation for fetchSeriesValuesWithMatch
+ (mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation(
+ (_timeRange: TimeRange, label: string) => {
+ if (label === 'job') {
+ return Promise.resolve(['grafana', 'prometheus']);
+ }
+ if (label === 'instance') {
+ return Promise.resolve(['host1', 'host2']);
+ }
+ if (label === METRIC_LABEL) {
+ return Promise.resolve(['metric1', 'metric2', 'metric3']);
+ }
+ return Promise.resolve([]);
+ }
+ );
+
+ // Mock fetchSeriesLabelsMatch
+ (mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({
+ __name__: ['metric1', 'metric2'],
+ instance: ['instance1', 'instance2'],
+ job: ['job1', 'job2'],
+ service: ['service1', 'service2'],
+ });
+ });
+
+ afterEach(() => {
+ // Restore console methods
+ console.log = originalConsoleLog;
+ console.error = originalConsoleError;
+ });
+
+ it('should initialize by fetching metrics from language provider', async () => {
+ renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for the metrics to be populated from the mock
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
+ expect.anything(),
+ METRIC_LABEL,
+ undefined,
+ 'MetricsBrowser_M',
+ DEFAULT_SERIES_LIMIT
+ );
+ });
+
+ it('should fetch label keys during initialization', async () => {
+ renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for the fetchLabels to be called
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchLabels).toHaveBeenCalled();
+ });
+
+ expect(mockLanguageProvider.fetchLabels).toHaveBeenCalledWith(expect.anything(), undefined, DEFAULT_SERIES_LIMIT);
+ });
+
+ it('should load saved label keys from localStorage and fetch values', async () => {
+ // Set up localStorage with saved label keys
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance']));
+
+ renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for the fetchSeriesValuesWithMatch to be called for both job and instance
+ await waitFor(() => {
+ const fetchCalls = (mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mock.calls;
+ const jobCall = fetchCalls.find((call) => call[1] === 'job' && call[3] === 'MetricsBrowser_LV_job');
+ const instanceCall = fetchCalls.find(
+ (call) => call[1] === 'instance' && call[3] === 'MetricsBrowser_LV_instance'
+ );
+ return jobCall && instanceCall;
+ });
+
+ // Verify that fetchSeriesValuesWithMatch was called with the expected parameters for job
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
+ expect.anything(),
+ 'job',
+ undefined,
+ 'MetricsBrowser_LV_job',
+ DEFAULT_SERIES_LIMIT
+ );
+
+ // Verify that fetchSeriesValuesWithMatch was called with the expected parameters for instance
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
+ expect.anything(),
+ 'instance',
+ undefined,
+ 'MetricsBrowser_LV_instance',
+ DEFAULT_SERIES_LIMIT
+ );
+ });
+
+ it('should set label values as string arrays', async () => {
+ // Set up localStorage with saved label keys
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job']));
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for the label values to be set
+ await waitFor(() => {
+ return result.current.labelValues.job !== undefined;
+ });
+
+ // Verify label values are stored as string arrays
+ expect(Array.isArray(result.current.labelValues.job)).toBe(true);
+ expect(result.current.labelValues.job).toEqual(['grafana', 'prometheus']);
+ });
+
+ it('should update timeRange reference for significant changes', async () => {
+ const { rerender } = renderHook(({ timeRange, provider }) => useMetricsLabelsValues(timeRange, provider), {
+ initialProps: {
+ timeRange: mockTimeRange,
+ provider: mockLanguageProvider,
+ },
+ });
+
+ // Wait for initial fetch to complete
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ // Clear mock calls
+ jest.clearAllMocks();
+
+ // Update with new time range that is 10 seconds different (above 5 second threshold)
+ const significantChangedTimeRange = {
+ ...mockTimeRange,
+ from: mockTimeRange.from.add(10, 'seconds'),
+ to: mockTimeRange.to.add(10, 'seconds'),
+ };
+
+ rerender({
+ timeRange: significantChangedTimeRange,
+ provider: mockLanguageProvider,
+ });
+
+ // The timeRangeRef is updated but shouldn't trigger a refetch
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).not.toHaveBeenCalled();
+ });
+
+ describe('handleSelectedMetricChange', () => {
+ it('should select a metric when not previously selected', async () => {
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for initialization
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ // Clear mock calls before testing
+ jest.clearAllMocks();
+
+ // Select a metric
+ await act(async () => {
+ await result.current.handleSelectedMetricChange('metric1');
+ });
+
+ // Verify the metric was selected
+ expect(result.current.selectedMetric).toBe('metric1');
+ });
+
+ it('should deselect a metric when the same metric is selected again', async () => {
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for initialization
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ // First select a metric
+ await act(async () => {
+ await result.current.handleSelectedMetricChange('metric1');
+ });
+
+ expect(result.current.selectedMetric).toBe('metric1');
+
+ // Clear mock calls
+ jest.clearAllMocks();
+
+ // Deselect by selecting the same metric
+ await act(async () => {
+ await result.current.handleSelectedMetricChange('metric1');
+ });
+
+ // Verify the metric was deselected
+ expect(result.current.selectedMetric).toBe('');
+ });
+
+ it('should update label keys and values when a metric is selected', async () => {
+ // Mock fetchSeriesLabelsMatch to return specific labels
+ (mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({
+ __name__: ['metric1'],
+ job: ['job1', 'job2'],
+ instance: ['instance1', 'instance2'],
+ service: ['service1', 'service2'],
+ });
+
+ // Start with some selected label keys
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance', 'service']));
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for initialization
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ return result.current.selectedLabelKeys.length === 3;
+ });
+
+ // Clear mock calls
+ jest.clearAllMocks();
+
+ // Select a metric
+ await act(async () => {
+ await result.current.handleSelectedMetricChange('metric1');
+ });
+
+ // Verify that the label keys were updated to those returned by fetchSeriesLabelsMatch
+ expect(result.current.labelKeys).toContain('job');
+ expect(result.current.labelKeys).toContain('instance');
+ expect(result.current.labelKeys).toContain('service');
+
+ // Verify that selected label keys were filtered to only include those available for the metric
+ expect(result.current.selectedLabelKeys).toContain('job');
+ expect(result.current.selectedLabelKeys).toContain('instance');
+ expect(result.current.selectedLabelKeys).toContain('service');
+
+ // Verify that label values were fetched
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleSelectedLabelKeyChange', () => {
+ it('should add a label key when it is not already selected', async () => {
+ // Start with an empty selected label keys array
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for initialization
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ // Clear mock calls before testing handleSelectedLabelKeyChange
+ jest.clearAllMocks();
+
+ // Since we have no selected metric, the buildSelector will return EMPTY_SELECTOR
+ // and the hook will use undefined instead of the selector
+ (buildSelector as jest.Mock).mockReturnValue(EMPTY_SELECTOR);
+
+ // Add a new label key
+ await act(async () => {
+ await result.current.handleSelectedLabelKeyChange('service');
+ });
+
+ // Verify the label key was added
+ expect(result.current.selectedLabelKeys).toContain('service');
+
+ // Verify that buildSelector was called
+ expect(buildSelector).toHaveBeenCalled();
+
+ // Verify that fetchSeriesValuesWithMatch was called to get values for the new label key
+ // with undefined in place of the EMPTY_SELECTOR
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
+ expect.anything(),
+ 'service',
+ undefined, // Since selector is EMPTY_SELECTOR, it should be converted to undefined
+ 'MetricsBrowser_LV_service',
+ DEFAULT_SERIES_LIMIT
+ );
+
+ // Verify localStorage was updated
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(LAST_USED_LABELS_KEY, JSON.stringify(['service']));
+ });
+
+ it('should set selected label key from localStorage', async () => {
+ // Clear everything first
+ jest.clearAllMocks();
+ localStorageMock.clear();
+
+ // Mock fetchLabels to ensure it returns 'job' in the available labels
+ // This is critical because the hook filters localStorage labels against these values
+ (mockLanguageProvider.fetchLabels as jest.Mock).mockResolvedValue(['job', 'instance']);
+
+ // IMPORTANT: set up localStorage before the hook is initialized
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job']));
+
+ // Render the hook
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for the async initialization to complete
+ await waitFor(() => {
+ // Wait until fetchLabels has been called - this indicates the initialization started
+ expect(mockLanguageProvider.fetchLabels).toHaveBeenCalled();
+ // And wait until selectedLabelKeys includes 'job' - this indicates state was updated
+ return result.current.selectedLabelKeys.includes('job');
+ });
+
+ // Now verify the state after initialization
+ expect(result.current.selectedLabelKeys).toEqual(['job']);
+ });
+
+ it('should remove a label key when it is already selected', async () => {
+ // Clear everything first
+ jest.clearAllMocks();
+ localStorageMock.clear();
+
+ // IMPORTANT: set up localStorage before the hook is initialized
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job']));
+
+ // Mock fetchLabels to specifically include 'job' in the returned keys
+ // This is critical because the hook filters localStorage labels against these values
+ (mockLanguageProvider.fetchLabels as jest.Mock).mockImplementation(() => {
+ return Promise.resolve(['job']);
+ });
+
+ // Simple mock for fetchSeriesValuesWithMatch
+ (mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation((_, label) => {
+ if (label === 'job') {
+ return Promise.resolve(['job-value']);
+ }
+ if (label === METRIC_LABEL) {
+ return Promise.resolve(['metric1']);
+ }
+ return Promise.resolve(['value1']);
+ });
+
+ // Render the hook
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for initialization to complete
+ await waitFor(
+ () => {
+ // Wait until fetchLabels has been called - this indicates the initialization started
+ expect(mockLanguageProvider.fetchLabels).toHaveBeenCalled();
+ // And wait until selectedLabelKeys includes 'job' - this indicates state was updated
+ return result.current.selectedLabelKeys.includes('job');
+ },
+ { timeout: 5000 }
+ );
+
+ // Verify the initial state
+ expect(result.current.selectedLabelKeys).toEqual(['job']);
+
+ // Mock localStorage.setItem to verify it's called correctly
+ localStorageMock.setItem.mockClear();
+
+ // Use a simplified approach - just call handleSelectedLabelKeyChange directly
+ await act(async () => {
+ await result.current.handleSelectedLabelKeyChange('job');
+ });
+
+ // Skip the waitFor and directly verify the state was updated
+ expect(result.current.selectedLabelKeys).not.toContain('job');
+ expect(result.current.selectedLabelKeys).toEqual([]);
+
+ // Verify label values were removed
+ expect(result.current.labelValues).not.toHaveProperty('job');
+
+ // Verify localStorage was updated
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(LAST_USED_LABELS_KEY, JSON.stringify([]));
+ });
+
+ it('should handle labelKey changes when a metric is selected', async () => {
+ // Setup with a selected metric
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for initialization
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ // Select a metric first
+ await act(async () => {
+ await result.current.handleSelectedMetricChange('metric1');
+ });
+
+ // Clear mock calls
+ jest.clearAllMocks();
+
+ // Mock the buildSelector to return a non-empty selector
+ (buildSelector as jest.Mock).mockReturnValue('metric1{instance="host1"}');
+
+ // Add a label key
+ await act(async () => {
+ await result.current.handleSelectedLabelKeyChange('service');
+ });
+
+ // Verify buildSelector was called with the selected metric and label values
+ expect(buildSelector).toHaveBeenCalled();
+
+ // Verify fetchSeriesValuesWithMatch was called with the correct selector
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
+ expect.anything(),
+ 'service',
+ 'metric1{instance="host1"}', // The selector returned by our mock
+ 'MetricsBrowser_LV_service',
+ DEFAULT_SERIES_LIMIT
+ );
+ });
+ });
+
+ describe('handleSelectedLabelValueChange', () => {
+ it('should add a label value when isSelected is true', async () => {
+ // Start with selected label keys but no selected values
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job']));
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for initialization
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ // Clear mock calls
+ jest.clearAllMocks();
+
+ // Select a label value
+ await act(async () => {
+ await result.current.handleSelectedLabelValueChange('job', 'grafana', true);
+ });
+
+ // Verify the value was added to selectedLabelValues
+ expect(result.current.selectedLabelValues.job).toContain('grafana');
+
+ // Verify buildSelector was called to create a selector with the selected value
+ expect(buildSelector).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ job: ['grafana'] }));
+
+ // Verify metrics were fetched with the new selector
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
+ expect.anything(),
+ METRIC_LABEL,
+ `metric1{instance="host1"}`,
+ 'MetricsBrowser_M',
+ DEFAULT_SERIES_LIMIT
+ );
+
+ // Verify label keys were fetched for the filtered metrics
+ expect(mockLanguageProvider.fetchSeriesLabelsMatch).toHaveBeenCalled();
+ });
+
+ it('should remove a label value when isSelected is false', async () => {
+ // Setup initial state with selected label key and value
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job']));
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for initialization
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ // First select a value
+ await act(async () => {
+ await result.current.handleSelectedLabelValueChange('job', 'grafana', true);
+ });
+
+ // Verify initial selection
+ expect(result.current.selectedLabelValues.job).toContain('grafana');
+
+ // Clear mock calls
+ jest.clearAllMocks();
+
+ // Now deselect the value
+ await act(async () => {
+ await result.current.handleSelectedLabelValueChange('job', 'grafana', false);
+ });
+
+ // Verify that 'job' key is no longer in selectedLabelValues
+ expect(Object.keys(result.current.selectedLabelValues)).not.toContain('job');
+ });
+
+ it('should preserve values for the last selected label key', async () => {
+ // Mock with specific return values to test value merging
+ (mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation(
+ (_timeRange: TimeRange, label: string) => {
+ if (label === 'job') {
+ // Return a smaller set of values on refetch
+ return Promise.resolve(['grafana']);
+ }
+ if (label === 'instance') {
+ return Promise.resolve(['host1', 'host2']);
+ }
+ if (label === METRIC_LABEL) {
+ return Promise.resolve(['metric1', 'metric2']);
+ }
+ return Promise.resolve([]);
+ }
+ );
+
+ // Start with selected label keys
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance']));
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for initialization
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ // Initialize job values with a larger set
+ const initialJobValues = ['grafana', 'prometheus', 'additional_value'];
+
+ // Select a value for job (should set job as last selected)
+ await act(async () => {
+ await result.current.handleSelectedLabelValueChange('job', 'grafana', true);
+ });
+
+ // Mock a more extensive set of label values for job
+ (mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation(
+ (_timeRange: TimeRange, label: string) => {
+ if (label === 'job') {
+ return Promise.resolve(initialJobValues);
+ }
+ if (label === 'instance') {
+ return Promise.resolve(['host1', 'host2']);
+ }
+ if (label === METRIC_LABEL) {
+ return Promise.resolve(['metric1', 'metric2']);
+ }
+ return Promise.resolve([]);
+ }
+ );
+
+ // Select a value for instance to trigger job values refetch
+ await act(async () => {
+ await result.current.handleSelectedLabelValueChange('instance', 'host1', true);
+ });
+
+ // Select job value again to make it the last selected
+ await act(async () => {
+ await result.current.handleSelectedLabelValueChange('job', 'prometheus', true);
+ });
+
+ // Verify that job values contain all the values
+ expect(result.current.labelValues.job).toContain('grafana');
+ expect(result.current.labelValues.job).toContain('prometheus');
+ expect(result.current.labelValues.job).toContain('additional_value');
+ });
+
+ it('should only update selected values for non-last-selected keys', async () => {
+ // Setup mock to return different values for initial and subsequent calls
+ let callCount = 0;
+ (mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation(
+ (_timeRange: TimeRange, label: string) => {
+ if (label === 'job') {
+ return Promise.resolve(['grafana', 'prometheus']);
+ }
+ if (label === 'instance') {
+ // Return different values on first vs subsequent calls
+ callCount++;
+ if (callCount === 1) {
+ return Promise.resolve(['host1', 'host2']);
+ } else {
+ return Promise.resolve(['host3']);
+ }
+ }
+ if (label === METRIC_LABEL) {
+ return Promise.resolve(['metric1', 'metric2']);
+ }
+ return Promise.resolve([]);
+ }
+ );
+
+ // Start with selected label keys
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance']));
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for initialization
+ await waitFor(() => {
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
+ });
+
+ // First set a selected value for instance
+ await act(async () => {
+ await result.current.handleSelectedLabelValueChange('instance', 'host1', true);
+ });
+
+ // Verify initial selection
+ expect(result.current.selectedLabelValues.instance).toContain('host1');
+
+ // Clear mock calls
+ jest.clearAllMocks();
+
+ // Now select a value for job (instance is not last selected anymore)
+ await act(async () => {
+ await result.current.handleSelectedLabelValueChange('job', 'grafana', true);
+ });
+
+ // Check that newly fetched instance values were intersected with selected values
+ expect(result.current.labelValues.instance).toContain('host3');
+ });
+
+ it('should handle errors during label values fetching', async () => {
+ // Mock fetchSeriesValuesWithMatch to throw an error for specific labels
+ (mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation(
+ (_timeRange: TimeRange, label: string, _selector: string, debugName: string) => {
+ if (label === METRIC_LABEL) {
+ return Promise.resolve(['metric1', 'metric2']);
+ }
+ if (label === 'job' && debugName === 'MetricsBrowser_LV_job') {
+ return Promise.reject(new Error('Test error'));
+ }
+ if (label === 'instance') {
+ return Promise.resolve(['host1', 'host2']);
+ }
+ return Promise.resolve([]);
+ }
+ );
+
+ // Start with selected label keys
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance']));
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Wait for the error to be logged
+ await waitFor(() => {
+ // Use a loop to find console.error calls with our test error
+ const errorCalls = (console.error as jest.Mock).mock.calls;
+ return errorCalls.some((call) => call[0] instanceof Error && call[0].message === 'Test error');
+ });
+
+ // Wait for initialization to complete so we can verify the result
+ await waitFor(() => {
+ return result.current.labelValues.instance !== undefined;
+ });
+
+ // Verify that instance values were still fetched successfully
+ expect(result.current.labelValues).toHaveProperty('instance');
+
+ // Verify job is not in labelValues since its fetch failed
+ expect(result.current.labelValues).not.toHaveProperty('job');
+ });
+ });
+
+ describe('helper functions', () => {
+ describe('buildSafeSelector', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should convert EMPTY_SELECTOR to undefined', async () => {
+ (buildSelector as jest.Mock).mockReturnValue(EMPTY_SELECTOR);
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // We need to access the helper function in a test-friendly way
+ const buildSafeSelector = result.current.buildSafeSelector;
+
+ expect(buildSafeSelector('metric1', {})).toBeUndefined();
+ });
+
+ it('should return the selector value when not empty', async () => {
+ const expectedSelector = 'metric1{job="prometheus"}';
+ (buildSelector as jest.Mock).mockReturnValue(expectedSelector);
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ const buildSafeSelector = result.current.buildSafeSelector;
+
+ expect(buildSafeSelector('metric1', { job: ['prometheus'] })).toBe(expectedSelector);
+ });
+ });
+
+ describe('loadSelectedLabelsFromStorage', () => {
+ it('should filter labels against available labels', async () => {
+ localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance', 'unavailable']));
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ const loadSelectedLabelsFromStorage = result.current.loadSelectedLabelsFromStorage;
+
+ const availableLabels = ['job', 'instance', 'pod'];
+ expect(loadSelectedLabelsFromStorage(availableLabels)).toEqual(['job', 'instance']);
+ });
+
+ it('should handle empty localStorage', async () => {
+ localStorageMock.clear();
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ const loadSelectedLabelsFromStorage = result.current.loadSelectedLabelsFromStorage;
+
+ expect(loadSelectedLabelsFromStorage(['job', 'instance'])).toEqual([]);
+ });
+ });
+
+ describe('fetchMetrics', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should fetch metrics with the provided selector', async () => {
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Clear previous calls
+ jest.clearAllMocks();
+
+ const fetchMetrics = result.current.fetchMetrics;
+
+ await act(async () => {
+ await fetchMetrics('selector');
+ });
+
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
+ expect.anything(),
+ METRIC_LABEL,
+ 'selector',
+ 'MetricsBrowser_M',
+ DEFAULT_SERIES_LIMIT
+ );
+ });
+ });
+
+ describe('fetchLabelKeys', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should fetch label keys with no selector', async () => {
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Clear previous calls
+ jest.clearAllMocks();
+
+ const fetchLabelKeys = result.current.fetchLabelKeys;
+
+ await act(async () => {
+ await fetchLabelKeys();
+ });
+
+ expect(mockLanguageProvider.fetchLabels).toHaveBeenCalledWith(
+ expect.anything(),
+ undefined,
+ DEFAULT_SERIES_LIMIT
+ );
+ });
+
+ it('should fetch label keys with a selector', async () => {
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Clear previous calls
+ jest.clearAllMocks();
+
+ const fetchLabelKeys = result.current.fetchLabelKeys;
+
+ await act(async () => {
+ await fetchLabelKeys('selector');
+ });
+
+ expect(mockLanguageProvider.fetchSeriesLabelsMatch).toHaveBeenCalledWith(
+ expect.anything(),
+ 'selector',
+ DEFAULT_SERIES_LIMIT
+ );
+ });
+
+ it('should handle errors during fetching', async () => {
+ (mockLanguageProvider.fetchLabels as jest.Mock).mockRejectedValueOnce(new Error('Test error'));
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ const fetchLabelKeys = result.current.fetchLabelKeys;
+
+ let labelKeys;
+ await act(async () => {
+ labelKeys = await fetchLabelKeys();
+ });
+
+ expect(labelKeys).toEqual([]);
+ expect(result.current.err).toContain('Error fetching labels');
+ });
+ });
+
+ describe('fetchLabelValues', () => {
+ it('should fetch values for multiple label keys', async () => {
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ // Clear previous calls
+ jest.clearAllMocks();
+
+ const fetchLabelValues = result.current.fetchLabelValues;
+
+ await act(async () => {
+ await fetchLabelValues(['job', 'instance']);
+ });
+
+ // Verify calls for both label keys
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
+ expect.anything(),
+ 'job',
+ undefined,
+ 'MetricsBrowser_LV_job',
+ DEFAULT_SERIES_LIMIT
+ );
+
+ expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
+ expect.anything(),
+ 'instance',
+ undefined,
+ 'MetricsBrowser_LV_instance',
+ DEFAULT_SERIES_LIMIT
+ );
+ });
+
+ it('should handle errors for individual label keys', async () => {
+ (mockLanguageProvider.fetchSeriesValuesWithMatch as jest.Mock).mockImplementation((_timeRange, label) => {
+ if (label === 'job') {
+ return Promise.reject(new Error('Test error'));
+ }
+ return Promise.resolve(['value1', 'value2']);
+ });
+
+ const { result } = renderHook(() => useMetricsLabelsValues(mockTimeRange, mockLanguageProvider));
+
+ const fetchLabelValues = result.current.fetchLabelValues;
+
+ let values;
+ await act(async () => {
+ const response = await fetchLabelValues(['job', 'instance']);
+ if (response) {
+ [values] = response;
+ // Should contain values for instance but not for job
+ expect(values).not.toHaveProperty('job');
+ expect(values).toHaveProperty('instance');
+ }
+ });
+
+ expect(result.current.err).toContain('Error fetching label values');
+ });
+ });
+ });
+});
diff --git a/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts
new file mode 100644
index 00000000000..81650568b44
--- /dev/null
+++ b/packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts
@@ -0,0 +1,384 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { TimeRange } from '@grafana/data';
+
+import PromQlLanguageProvider from '../../language_provider';
+
+import { buildSelector } from './selectorBuilder';
+import { DEFAULT_SERIES_LIMIT, EMPTY_SELECTOR, LAST_USED_LABELS_KEY, Metric, METRIC_LABEL } from './types';
+
+export const useMetricsLabelsValues = (timeRange: TimeRange, languageProvider: PromQlLanguageProvider) => {
+ const timeRangeRef = useRef(timeRange);
+
+ const [seriesLimit, setSeriesLimit] = useState(DEFAULT_SERIES_LIMIT);
+ const [err, setErr] = useState('');
+ const [status, setStatus] = useState('Ready');
+ const [validationStatus, setValidationStatus] = useState('');
+
+ const [metrics, setMetrics] = useState([]);
+ const [selectedMetric, setSelectedMetric] = useState('');
+ const [labelKeys, setLabelKeys] = useState([]);
+ const [selectedLabelKeys, setSelectedLabelKeys] = useState([]);
+ const [lastSelectedLabelKey, setLastSelectedLabelKey] = useState('');
+ const [labelValues, setLabelValues] = useState>({});
+ const [selectedLabelValues, setSelectedLabelValues] = useState>({});
+
+ // We don't want to trigger fetching for small amount of time changes.
+ // When MetricsBrowser re-renders for any reason we might receive a new timerange.
+ // This particularly happens when we have relative time ranges: from: now, to: now-1h
+ useEffect(() => {
+ if (
+ timeRange.to.diff(timeRangeRef.current.to, 'second') >= 5 &&
+ timeRange.from.diff(timeRangeRef.current.from, 'second') >= 5
+ ) {
+ timeRangeRef.current = timeRange;
+ }
+ }, [timeRange]);
+
+ //Handler for error processing - logs the error and updates UI state
+ const handleError = useCallback((e: unknown, msg: string) => {
+ if (e instanceof Error) {
+ setErr(`${msg}: ${e.message}`);
+ } else {
+ setErr(`${msg}: Unknown error`);
+ }
+ setStatus('');
+ }, []);
+
+ // Get metadata details for a metric if available
+ const getMetricDetails = useCallback(
+ (metricName: string) => {
+ const meta = languageProvider.metricsMetadata;
+ return meta && meta[metricName] ? `(${meta[metricName].type}) ${meta[metricName].help}` : undefined;
+ },
+ [languageProvider.metricsMetadata]
+ );
+
+ // Builds a safe selector string from metric name and label values
+ // Prometheus API doesn't allow empty matchers. This is bad => match[]={}
+ // Converts EMPTY_SELECTOR to undefined as some API calls need that
+ const buildSafeSelector = useCallback((metric: string, labelValues: Record) => {
+ const selector = buildSelector(metric, labelValues);
+ return selector === EMPTY_SELECTOR ? undefined : selector;
+ }, []);
+
+ // Loads label keys from localStorage and filters them against available labels
+ // This ensures we only show label keys that are actually available in the current context
+ const loadSelectedLabelsFromStorage = useCallback(
+ (availableLabelKeys: string[]) => {
+ try {
+ const labelKeysInLocalStorageAsString = localStorage.getItem(LAST_USED_LABELS_KEY) || '[]';
+ const labelKeysInLocalStorage = JSON.parse(labelKeysInLocalStorageAsString);
+ return labelKeysInLocalStorage.filter((slk: string) => availableLabelKeys.includes(slk));
+ } catch (e) {
+ handleError(e, 'Failed to load saved label keys');
+ return [];
+ }
+ },
+ [handleError]
+ );
+
+ // Fetches metrics that match the given selector
+ // Transforms raw metric strings into Metric objects with metadata
+ const fetchMetrics = useCallback(
+ async (safeSelector?: string) => {
+ try {
+ const fetchedMetrics = await languageProvider.fetchSeriesValuesWithMatch(
+ timeRangeRef.current,
+ METRIC_LABEL,
+ safeSelector,
+ 'MetricsBrowser_M',
+ seriesLimit
+ );
+ return fetchedMetrics.map((m) => ({
+ name: m,
+ details: getMetricDetails(m),
+ }));
+ } catch (e) {
+ handleError(e, 'Error fetching metrics');
+ return [];
+ }
+ },
+ [getMetricDetails, handleError, languageProvider, seriesLimit]
+ );
+
+ // Fetches label keys based on an optional selector
+ // Uses different APIs depending on whether a selector is provided
+ const fetchLabelKeys = useCallback(
+ async (safeSelector?: string) => {
+ try {
+ if (safeSelector) {
+ return Object.keys(
+ await languageProvider.fetchSeriesLabelsMatch(timeRangeRef.current, safeSelector, seriesLimit)
+ );
+ } else {
+ return (await languageProvider.fetchLabels(timeRangeRef.current, undefined, seriesLimit)) || [];
+ }
+ } catch (e) {
+ handleError(e, 'Error fetching labels');
+ return [];
+ }
+ },
+ [handleError, languageProvider, seriesLimit]
+ );
+
+ // Fetches values for multiple label keys and also prepares selected values
+ const fetchLabelValues = useCallback(
+ async (labelKeys: string[], safeSelector?: string) => {
+ const transformedLabelValues: Record = {};
+ const newSelectedLabelValues: Record = {};
+ for (const lk of labelKeys) {
+ try {
+ const values = await languageProvider.fetchSeriesValuesWithMatch(
+ timeRangeRef.current,
+ lk,
+ safeSelector,
+ `MetricsBrowser_LV_${lk}`,
+ seriesLimit
+ );
+ transformedLabelValues[lk] = values;
+ if (selectedLabelValues[lk]) {
+ newSelectedLabelValues[lk] = [...selectedLabelValues[lk]];
+ }
+ } catch (e) {
+ handleError(e, 'Error fetching label values');
+ }
+ }
+ return [transformedLabelValues, newSelectedLabelValues];
+ },
+ [handleError, languageProvider, selectedLabelValues, seriesLimit]
+ );
+
+ // Initial set up of the Metrics Browser
+ // This is called when "Clear" button clicked.
+ const initialize = useCallback(
+ async (metric: string, labelValues: Record) => {
+ const selector = buildSelector(metric, labelValues);
+ const safeSelector = selector === EMPTY_SELECTOR ? undefined : selector;
+
+ // Metrics
+ const transformedMetrics: Metric[] = await fetchMetrics(safeSelector);
+
+ // Labels
+ const transformedLabelKeys: string[] = await fetchLabelKeys(safeSelector);
+
+ // Selected Labels
+ const labelKeysInLocalStorage: string[] = loadSelectedLabelsFromStorage(transformedLabelKeys);
+
+ // Selected Labels' Values
+ const [transformedLabelValues] = await fetchLabelValues(labelKeysInLocalStorage, safeSelector);
+
+ setMetrics(transformedMetrics);
+ setLabelKeys(transformedLabelKeys);
+ setSelectedLabelKeys(labelKeysInLocalStorage);
+ setLabelValues(transformedLabelValues);
+ },
+ [fetchLabelKeys, fetchLabelValues, fetchMetrics, loadSelectedLabelsFromStorage]
+ );
+
+ // Initialize the hook
+ useEffect(() => {
+ initialize(selectedMetric, selectedLabelValues);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Handles metric selection changes.
+ // If a metric selected it fetches the labels of that metric
+ // Otherwise it fetches all the labels.
+ // Based on the fetched labels, label value list is updated.
+ // If a label key is not present, its values are removed from the list.
+ const handleSelectedMetricChange = async (metricName: string) => {
+ const newSelectedMetric = selectedMetric !== metricName ? metricName : '';
+ const selector = buildSafeSelector(newSelectedMetric, selectedLabelValues);
+ try {
+ const fetchedMetrics = await fetchMetrics(selector);
+ const fetchedLabelKeys = await fetchLabelKeys(selector);
+ const newSelectedLabelKeys = selectedLabelKeys.filter((slk) => fetchedLabelKeys.includes(slk));
+ const [transformedLabelValues, newSelectedLabelValues] = await fetchLabelValues(
+ newSelectedLabelKeys,
+ newSelectedMetric === '' ? undefined : selector
+ );
+
+ setMetrics(fetchedMetrics);
+ setSelectedMetric(newSelectedMetric);
+ setLabelKeys(fetchedLabelKeys);
+ setSelectedLabelKeys(newSelectedLabelKeys);
+ setLabelValues(transformedLabelValues);
+ setSelectedLabelValues(newSelectedLabelValues);
+ } catch (e: unknown) {
+ handleError(e, 'Error fetching labels');
+ }
+ };
+
+ // Handles when a label key selection changed
+ // If it's a selection, it fetches the values based on the up-to-date selector
+ // If it's a de-selection, it clears the values from the list
+ const handleSelectedLabelKeyChange = async (labelKey: string) => {
+ const newSelectedLabelKeys = [...selectedLabelKeys];
+ const lkIdx = newSelectedLabelKeys.indexOf(labelKey);
+ const newLabelValues: Record = { ...labelValues };
+ const newSelectedLabelValues: Record = { ...selectedLabelValues };
+
+ if (lkIdx === -1) {
+ // Label key is not in the selectedLabelKeys. Let's add it.
+ newSelectedLabelKeys.push(labelKey);
+ const safeSelector = buildSafeSelector(selectedMetric, selectedLabelValues);
+ const [values] = await fetchLabelValues([labelKey], safeSelector);
+ newLabelValues[labelKey] = values[labelKey];
+ } else {
+ // Label key is in the selectedLabelKeys. Removing it and its values.
+ newSelectedLabelKeys.splice(lkIdx, 1);
+ delete newLabelValues[labelKey];
+ delete newSelectedLabelValues[labelKey];
+ }
+
+ localStorage.setItem(LAST_USED_LABELS_KEY, JSON.stringify(newSelectedLabelKeys));
+ setSelectedLabelKeys(newSelectedLabelKeys);
+ setLabelValues(newLabelValues);
+ setSelectedLabelValues(newSelectedLabelValues);
+ };
+
+ // Handle the labelValue click based on isSelected value.
+ // If it is false we need to remove it from selected values
+ // If it is true then we need to add it to selected values
+ // Then we first fetch the values of each selected label key using the up-to-date selector
+ // We merged the fetched and existing list for the list we interact.
+ // Because we might want to select more labels from the same list.
+ // For other value lists we use the intersection of fetched and selected values.
+ // Then we fetch the metrics based on new selector we have after value fetch
+ // Then we fetch the labels keys of the metrics we fetched.
+ const handleSelectedLabelValueChange = async (labelKey: string, labelValue: string, isSelected: boolean) => {
+ const newSelectedLabelValues = { ...selectedLabelValues };
+ let newLastSelectedLabelKey = lastSelectedLabelKey;
+ if (labelKey !== lastSelectedLabelKey) {
+ newLastSelectedLabelKey = labelKey;
+ }
+
+ // Label value selected
+ if (isSelected) {
+ if (!newSelectedLabelValues[labelKey]) {
+ newSelectedLabelValues[labelKey] = [];
+ }
+ newSelectedLabelValues[labelKey].push(labelValue);
+ } else {
+ newSelectedLabelValues[labelKey].splice(newSelectedLabelValues[labelKey].indexOf(labelValue), 1);
+ if (newSelectedLabelValues[labelKey].length === 0) {
+ delete newSelectedLabelValues[labelKey];
+ }
+ }
+
+ let safeSelector = buildSafeSelector(selectedMetric, newSelectedLabelValues);
+
+ // Fetch new values
+ let newLabelValues: Record = {};
+ if (selectedLabelKeys.length !== 0) {
+ for (const lk of selectedLabelKeys) {
+ try {
+ const fetchedLabelValues = await languageProvider.fetchSeriesValuesWithMatch(
+ timeRange,
+ lk,
+ safeSelector,
+ `MetricsBrowser_LV_${lk}`,
+ seriesLimit
+ );
+
+ // We don't want to discard values from last selected list.
+ // User might want to select more.
+ if (newLastSelectedLabelKey === lk) {
+ newLabelValues[lk] = Array.from(new Set([...labelValues[lk], ...fetchedLabelValues]));
+ } else {
+ // If there are already selected values merge them with the fetched values.
+ newLabelValues[lk] = fetchedLabelValues;
+ // Discard selected label values if they are not in response
+ newSelectedLabelValues[lk] = (newSelectedLabelValues[lk] ?? []).filter((item) =>
+ fetchedLabelValues.includes(item)
+ );
+ }
+ } catch (e: unknown) {
+ handleError(e, 'Error fetching label values');
+ }
+ }
+ }
+
+ // rebuild the selector based on the new selected label values
+ safeSelector = buildSafeSelector(selectedMetric, newSelectedLabelValues);
+
+ // Fetch metrics
+ const newMetrics: Metric[] = await fetchMetrics(safeSelector);
+
+ // Fetch label keys
+ // If there is no metric or label value selected fetch all the keys instead of creating a selector
+ let newLabelKeys: string[] = [];
+ if (!safeSelector) {
+ newLabelKeys = await fetchLabelKeys(undefined);
+ } else {
+ const labelKeysSelector = `{${METRIC_LABEL}=~"${newMetrics.map((m) => m.name).join('|')}"}`;
+ newLabelKeys = await fetchLabelKeys(labelKeysSelector);
+ }
+ const newSelectedLabelKeys: string[] = loadSelectedLabelsFromStorage(newLabelKeys);
+
+ setMetrics(newMetrics);
+ setLabelKeys(newLabelKeys);
+ setSelectedLabelKeys(newSelectedLabelKeys);
+ setLastSelectedLabelKey(newLastSelectedLabelKey);
+ setLabelValues(newLabelValues);
+ setSelectedLabelValues(newSelectedLabelValues);
+ };
+
+ // Validating if the selections we have can create a valid query
+ const handleValidation = async () => {
+ const selector = buildSelector(selectedMetric, selectedLabelValues);
+ setValidationStatus(`Validating selector ${selector}`);
+ setErr('');
+
+ try {
+ const results = await languageProvider.fetchLabelsWithMatch(timeRangeRef.current, selector);
+ setValidationStatus(`Selector is valid (${Object.keys(results).length} labels found)`);
+ } catch (e) {
+ handleError(e, 'Validation failed');
+ setValidationStatus('');
+ }
+ };
+
+ // Clears all the selections even the ones in localStorage
+ const handleClear = () => {
+ localStorage.setItem(LAST_USED_LABELS_KEY, '[]');
+
+ setSelectedMetric('');
+ setSelectedLabelKeys([]);
+ setSelectedLabelValues({});
+
+ setErr('');
+ setStatus('Ready');
+ setValidationStatus('');
+
+ initialize('', {});
+ };
+
+ return {
+ err,
+ setErr,
+ status,
+ setStatus,
+ seriesLimit,
+ setSeriesLimit,
+ validationStatus,
+ metrics,
+ labelKeys,
+ labelValues,
+ selectedMetric,
+ selectedLabelKeys,
+ selectedLabelValues,
+ handleSelectedMetricChange,
+ handleSelectedLabelKeyChange,
+ handleSelectedLabelValueChange,
+ handleValidation,
+ handleClear,
+ // Helper functions - not part of the public API
+ buildSafeSelector,
+ loadSelectedLabelsFromStorage,
+ fetchMetrics,
+ fetchLabelKeys,
+ fetchLabelValues,
+ };
+};
diff --git a/packages/grafana-prometheus/src/index.ts b/packages/grafana-prometheus/src/index.ts
index 23161591542..c28a7b78ab5 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/metrics-browser/PrometheusMetricsBrowser';
+export { MetricsBrowser } from './components/metrics-browser/MetricsBrowser';
export { PromExemplarField } from './components/PromExemplarField';
export { PromExploreExtraField } from './components/PromExploreExtraField';
export { PromQueryEditorForAlerting } from './components/PromQueryEditorForAlerting';
diff --git a/packages/grafana-prometheus/src/language_provider.ts b/packages/grafana-prometheus/src/language_provider.ts
index 9f0bbf259c9..685d8d62117 100644
--- a/packages/grafana-prometheus/src/language_provider.ts
+++ b/packages/grafana-prometheus/src/language_provider.ts
@@ -199,8 +199,8 @@ export default class PromQlLanguageProvider extends LanguageProvider {
}
}
- fetchLabelValues = async (range: TimeRange, key: string): Promise => {
- const params = this.datasource.getAdjustedInterval(range);
+ fetchLabelValues = async (range: TimeRange, key: string, limit?: string): Promise => {
+ const params = { ...this.datasource.getAdjustedInterval(range), ...(limit ? { limit } : {}) };
const interpolatedName = this.datasource.interpolateString(key);
const interpolatedAndEscapedName = escapeForUtf8Support(removeQuotesIfExist(interpolatedName));
const url = `/api/v1/label/${interpolatedAndEscapedName}/values`;
@@ -215,12 +215,12 @@ export default class PromQlLanguageProvider extends LanguageProvider {
/**
* Fetches all label keys
*/
- fetchLabels = async (timeRange: TimeRange, queries?: PromQuery[]): Promise => {
+ fetchLabels = async (timeRange: TimeRange, queries?: PromQuery[], limit?: string): Promise => {
let url = '/api/v1/labels';
const timeParams = this.datasource.getAdjustedInterval(timeRange);
this.labelFetchTs = Date.now().valueOf();
- const searchParams = new URLSearchParams({ ...timeParams });
+ const searchParams = new URLSearchParams({ ...timeParams, ...(limit ? { limit } : {}) });
queries?.forEach((q) => {
const visualQuery = buildVisualQueryFromString(q.expr);
if (visualQuery.query.metric !== '') {
@@ -262,16 +262,13 @@ export default class PromQlLanguageProvider extends LanguageProvider {
/**
* Fetches all values for a label, with optional match[]
- * @param name
- * @param match
- * @param timeRange
- * @param requestId
*/
fetchSeriesValuesWithMatch = async (
timeRange: TimeRange,
name: string,
- match: string,
- requestId?: string
+ match?: string,
+ requestId?: string,
+ withLimit?: string
): Promise => {
const interpolatedName = name ? this.datasource.interpolateString(name) : null;
const interpolatedMatch = match ? this.datasource.interpolateString(match) : null;
@@ -279,6 +276,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
const urlParams = {
...range,
...(interpolatedMatch && { 'match[]': interpolatedMatch }),
+ ...(withLimit ? { limit: withLimit } : {}),
};
let requestOptions: Partial | undefined = {
...this.getDefaultCacheHeaders(),
@@ -305,9 +303,6 @@ export default class PromQlLanguageProvider extends LanguageProvider {
* Function to replace old getSeries calls in a way that will provide faster endpoints for new prometheus instances,
* while maintaining backward compatability. The old API call got the labels and the values in a single query,
* but with the new query we need two calls, one to get the labels, and another to get the values.
- *
- * @param selector
- * @param otherLabels
*/
getSeriesLabels = async (timeRange: TimeRange, selector: string, otherLabels: Label[]): Promise => {
let possibleLabelNames, data: Record;
@@ -333,10 +328,11 @@ export default class PromQlLanguageProvider extends LanguageProvider {
fetchLabelsWithMatch = async (
timeRange: TimeRange,
name: string,
- withName?: boolean
+ withName?: boolean,
+ withLimit?: string
): Promise> => {
if (this.datasource.hasLabelsMatchAPISupport()) {
- return this.fetchSeriesLabelsMatch(timeRange, name, withName);
+ return this.fetchSeriesLabelsMatch(timeRange, name, withLimit);
} else {
return this.fetchSeriesLabels(timeRange, name, withName, REMOVE_SERIES_LIMIT);
}
@@ -377,13 +373,14 @@ export default class PromQlLanguageProvider extends LanguageProvider {
fetchSeriesLabelsMatch = async (
timeRange: TimeRange,
name: string,
- withName?: boolean
+ withLimit?: string
): Promise> => {
const interpolatedName = this.datasource.interpolateString(name);
const range = this.datasource.getAdjustedInterval(timeRange);
const urlParams = {
...range,
'match[]': interpolatedName,
+ ...(withLimit ? { limit: withLimit } : {}),
};
const url = `/api/v1/labels`;
@@ -414,13 +411,6 @@ export default class PromQlLanguageProvider extends LanguageProvider {
/**
* Fetch labels or values for a label based on the queries, scopes, filters and time range
- * @param timeRange
- * @param queries
- * @param scopes
- * @param adhocFilters
- * @param labelName
- * @param limit
- * @param requestId
*/
fetchSuggestions = async (
timeRange?: TimeRange,