Prometheus: Remove /series endpoint usage from Metrics Browser (#101705)

* 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

* remove /series endpoint usages for Metrics Browser

* add time ranges

* remove unused file

* fix unit test

* use limit

* re-fetch labels when a label value is selected

* reset the label keys when selector is empty

* select multiple labelValue

* fix unit tests

* add comments

* don't show unpresented labelKeys

* don't clear out selected label keys that are in local storage

* show values properly

* fix clearing

* don't discard already selected label values when a new label value selected

* remove unnecessary unit test

* clear all selections

* if metrics list is empty remove all label keys too

* re-populate values when all label selections are removed

* safety

* more comments

* updating the whole fetching mechanism

* buildSafeSelector

* loadSelectedLabelsFromStorage

* fetchMetrics

* fetchLabelKeys

* fetchLabelValues

* more unit test and comment

* timeRange changes will trigger re-fetch

* some more comments

* fix the unit test

* lint

* fix unit tests

* fix timerange causing problem

* remove fethcing message

* proper initialization

* fix value fetching during metric selection

* fix value fetching

* fix unit tests

* fix a bug

* comments

* some precaution

* fix the test

* fetch metrics when metric selection is changed

* trigger initialize when seriesLimit is change

* Revert "trigger initialize when seriesLimit is change"

This reverts commit c6cbbc1386.

* remove redundant comments
This commit is contained in:
ismail simsek
2025-04-16 08:36:23 +02:00
committed by GitHub
parent 74487726fc
commit 10eb8aeeee
18 changed files with 2299 additions and 1311 deletions
@@ -12,7 +12,8 @@ import { getInitHints } from '../query_hints';
import { PromOptions, PromQuery } from '../types';
import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise';
import { PrometheusMetricsBrowser } from './metrics-browser/PrometheusMetricsBrowser';
import { MetricsBrowser } from './metrics-browser/MetricsBrowser';
import { MetricsBrowserProvider } from './metrics-browser/MetricsBrowserContext';
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper';
import { useMetricsState } from './useMetricsState';
import { usePromQueryFieldEffects } from './usePromQueryFieldEffects';
@@ -172,11 +173,13 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
</div>
{labelBrowserVisible && (
<div className="gf-form">
<PrometheusMetricsBrowser
<MetricsBrowserProvider
timeRange={range ?? getDefaultTimeRange()}
languageProvider={languageProvider}
onChange={onChangeLabelBrowser}
timeRange={range}
/>
>
<MetricsBrowser />
</MetricsBrowserProvider>
</div>
)}
{ExtraFieldElement}
@@ -1,25 +1,23 @@
import { ChangeEvent, MouseEvent } from 'react';
import { useMemo, useState } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Input, Label, BrowserLabel as PromLabel } from '@grafana/ui';
import { BrowserLabel as PromLabel, Input, Label, useStyles2 } from '@grafana/ui';
import { SelectableLabel } from './types';
import { useMetricsBrowser } from './MetricsBrowserContext';
import { getStylesLabelSelector } from './styles';
import { METRIC_LABEL } from './types';
interface LabelSelectorProps {
nonMetricLabels: SelectableLabel[];
labelSearchTerm: string;
onChangeLabelSearch: (event: ChangeEvent<HTMLInputElement>) => void;
onClickLabel: (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => void;
styles: Record<string, string>;
}
export function LabelSelector() {
const styles = useStyles2(getStylesLabelSelector);
const [labelSearchTerm, setLabelSearchTerm] = useState('');
const { labelKeys, selectedLabelKeys, onLabelKeyClick } = useMetricsBrowser();
const filteredLabelKeys = useMemo(() => {
return labelKeys.filter(
(lk) => lk !== METRIC_LABEL && (selectedLabelKeys.includes(lk) || lk.includes(labelSearchTerm))
);
}, [labelKeys, labelSearchTerm, selectedLabelKeys]);
export function LabelSelector({
nonMetricLabels,
labelSearchTerm,
onChangeLabelSearch,
onClickLabel,
styles,
}: LabelSelectorProps) {
return (
<div className={styles.section}>
<Label description="Once label values are selected, only possible label combinations are shown.">
@@ -27,7 +25,7 @@ export function LabelSelector({
</Label>
<div>
<Input
onChange={onChangeLabelSearch}
onChange={(e) => setLabelSearchTerm(e.currentTarget.value)}
aria-label="Filter expression for label"
value={labelSearchTerm}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelNamesFilter}
@@ -35,15 +33,19 @@ export function LabelSelector({
</div>
{/* Using fixed height here to prevent jumpy layout */}
<div className={styles.list} style={{ height: 120 }}>
{nonMetricLabels.map((label) => (
{filteredLabelKeys.map((label) => (
<PromLabel
key={label.name}
name={label.name}
loading={label.loading}
active={label.selected}
hidden={label.hidden}
facets={label.facets}
onClick={onClickLabel}
key={label}
name={label}
loading={false}
active={selectedLabelKeys.includes(label)}
hidden={false}
facets={undefined}
onClick={(name: string) => {
// Resetting search to prevent empty results
setLabelSearchTerm('');
onLabelKeyClick(name);
}}
searchTerm={labelSearchTerm}
/>
))}
@@ -1,31 +1,21 @@
import { ChangeEvent, MouseEvent } from 'react';
import { useMemo, useState } from 'react';
import { FixedSizeList } from 'react-window';
import { selectors } from '@grafana/e2e-selectors';
import { BrowserLabel as PromLabel, Input, Label } from '@grafana/ui';
import { BrowserLabel as PromLabel, Input, Label, useStyles2 } from '@grafana/ui';
import { LIST_ITEM_SIZE, SelectableLabel } from './types';
import { useMetricsBrowser } from './MetricsBrowserContext';
import { getStylesMetricSelector } from './styles';
import { LIST_ITEM_SIZE } from './types';
interface MetricSelectorProps {
metrics: SelectableLabel | undefined;
metricSearchTerm: string;
seriesLimit: string;
onChangeMetricSearch: (event: ChangeEvent<HTMLInputElement>) => void;
onChangeSeriesLimit: (event: ChangeEvent<HTMLInputElement>) => void;
onClickMetric: (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => void;
styles: Record<string, string>;
}
export function MetricSelector() {
const styles = useStyles2(getStylesMetricSelector);
const [metricSearchTerm, setMetricSearchTerm] = useState('');
const { metrics, selectedMetric, seriesLimit, setSeriesLimit, onMetricClick } = useMetricsBrowser();
export function MetricSelector({
metrics,
metricSearchTerm,
seriesLimit,
onChangeMetricSearch,
onChangeSeriesLimit,
onClickMetric,
styles,
}: MetricSelectorProps) {
const metricCount = metrics?.values?.length || 0;
const filteredMetrics = useMemo(() => {
return metrics.filter((m) => m.name === selectedMetric || m.name.includes(metricSearchTerm));
}, [metrics, selectedMetric, metricSearchTerm]);
return (
<div>
@@ -35,7 +25,7 @@ export function MetricSelector({
</Label>
<div>
<Input
onChange={onChangeMetricSearch}
onChange={(e) => setMetricSearchTerm(e.currentTarget.value)}
aria-label="Filter expression for metric"
value={metricSearchTerm}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.selectMetric}
@@ -46,7 +36,7 @@ export function MetricSelector({
</Label>
<div>
<Input
onChange={onChangeSeriesLimit}
onChange={(e) => setSeriesLimit(e.currentTarget.value.trim())}
aria-label="Limit results from series endpoint"
value={seriesLimit}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.seriesLimit}
@@ -58,26 +48,27 @@ export function MetricSelector({
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.metricList}
>
<FixedSizeList
height={Math.min(450, metricCount * LIST_ITEM_SIZE)}
itemCount={metricCount}
height={Math.min(450, filteredMetrics.length * LIST_ITEM_SIZE)}
itemCount={filteredMetrics.length}
itemSize={LIST_ITEM_SIZE}
itemKey={(i) => metrics!.values![i].name}
itemKey={(i) => filteredMetrics[i].name}
width={300}
className={styles.valueList}
>
{({ index, style }) => {
const value = metrics?.values?.[index];
if (!value) {
return null;
}
const metric = filteredMetrics[index];
return (
<div style={style}>
<PromLabel
name={metrics!.name}
value={value?.name}
title={value.details}
active={value?.selected}
onClick={onClickMetric}
name={metric.name}
value={metric.name}
title={metric.details}
active={metric.name === selectedMetric}
onClick={(name: string, value: string | undefined) => {
// Resetting search to prevent empty results
setMetricSearchTerm('');
onMetricClick(name);
}}
searchTerm={metricSearchTerm}
/>
</div>
@@ -0,0 +1,27 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.tsx
import { Stack, useStyles2 } from '@grafana/ui';
import { LabelSelector } from './LabelSelector';
import { MetricSelector } from './MetricSelector';
import { SelectorActions } from './SelectorActions';
import { ValueSelector } from './ValueSelector';
import { getStylesMetricsBrowser } from './styles';
export const MetricsBrowser = () => {
const styles = useStyles2(getStylesMetricsBrowser);
return (
<div className={styles.wrapper}>
<Stack gap={3}>
<MetricSelector />
<div>
<LabelSelector />
<ValueSelector />
</div>
</Stack>
<SelectorActions />
</div>
);
};
@@ -0,0 +1,507 @@
import { render, renderHook, screen, waitFor } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { ReactNode } from 'react';
import { TimeRange } from '@grafana/data';
import PromQlLanguageProvider from '../../language_provider';
import { getMockTimeRange } from '../../test/__mocks__/datasource';
import { MetricsBrowserProvider, useMetricsBrowser } from './MetricsBrowserContext';
import { LAST_USED_LABELS_KEY, METRIC_LABEL } from './types';
// Mock the local storage
const localStorageMock = (() => {
let store: Record<string, string> = {};
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 language provider
const mockTimeRange = 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' },
},
fetchLabels: jest.fn(),
fetchSeriesLabelsMatch: jest.fn(),
fetchSeriesValuesWithMatch: jest.fn(),
fetchLabelsWithMatch: jest.fn(),
} as unknown as PromQlLanguageProvider;
const mockOnChange = jest.fn();
// Test component to render the context
const TestComponent = () => {
const {
metrics,
labelKeys,
selectedMetric,
selectedLabelKeys,
onMetricClick,
onLabelKeyClick,
onLabelValueClick,
getSelector,
onClearClick,
validationStatus,
onValidationClick,
} = useMetricsBrowser();
return (
<div>
<div data-testid="metrics-count">{metrics.length}</div>
<div data-testid="labels-count">{labelKeys.length}</div>
<div data-testid="selected-metric">{selectedMetric}</div>
<div data-testid="selected-label-keys">{selectedLabelKeys.join(',')}</div>
<div data-testid="selector">{getSelector()}</div>
<div data-testid="validation-status">{validationStatus}</div>
<button data-testid="select-metric" onClick={() => onMetricClick('metric1')}>
Select Metric
</button>
<button data-testid="select-label" onClick={() => onLabelKeyClick('job')}>
Select Label
</button>
<button data-testid="select-label-value" onClick={() => onLabelValueClick('job', 'grafana', true)}>
Select Label Value
</button>
<button data-testid="clear" onClick={onClearClick}>
Clear
</button>
<button data-testid="validate" onClick={onValidationClick}>
Validate
</button>
</div>
);
};
const renderWithProvider = (ui: ReactNode) => {
return render(
<MetricsBrowserProvider timeRange={mockTimeRange} languageProvider={mockLanguageProvider} onChange={mockOnChange}>
{ui}
</MetricsBrowserProvider>
);
};
describe('MetricsBrowserContext', () => {
beforeEach(() => {
jest.clearAllMocks();
localStorageMock.clear();
// Set up consistent mock implementation
(mockLanguageProvider.fetchLabels as jest.Mock).mockResolvedValue(['__name__', 'instance', 'job', 'service']);
// Default implementation for fetching metrics
(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 for fetching label keys for a specific metric
(mockLanguageProvider.fetchSeriesLabelsMatch as jest.Mock).mockResolvedValue({
__name__: ['metric1', 'metric2'],
instance: ['instance1', 'instance2'],
job: ['job1', 'job2'],
service: ['service1', 'service2'],
});
// Mock for validation
(mockLanguageProvider.fetchLabelsWithMatch as jest.Mock).mockResolvedValue({
job: ['job1', 'job2'],
instance: ['instance1', 'instance2'],
});
});
it('should initialize with metrics from language provider', async () => {
renderWithProvider(<TestComponent />);
// Wait for API calls and state update
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
expect.anything(),
METRIC_LABEL,
undefined,
'MetricsBrowser_M',
'40000'
);
},
{ timeout: 5000 }
);
// Then check UI update
await waitFor(
() => {
expect(screen.getByTestId('metrics-count').textContent).toBe('3');
},
{ timeout: 5000 }
);
});
it('should load saved label keys from localStorage on init', async () => {
// Setup localStorage with saved label keys
localStorageMock.setItem(LAST_USED_LABELS_KEY, JSON.stringify(['job', 'instance']));
renderWithProvider(<TestComponent />);
// First wait for API calls
await waitFor(
() => {
expect(mockLanguageProvider.fetchLabels).toHaveBeenCalled();
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
},
{ timeout: 5000 }
);
// Then wait for state update
await waitFor(
() => {
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job,instance');
},
{ timeout: 5000 }
);
});
it('should select and deselect metrics on click', async () => {
const user = userEvent.setup();
renderWithProvider(<TestComponent />);
// Wait for initialization to complete
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
},
{ timeout: 5000 }
);
// Initially no metric is selected
expect(screen.getByTestId('selected-metric').textContent).toBe('');
// Select a metric
await user.click(screen.getByTestId('select-metric'));
// Wait for selection to complete and API calls
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesLabelsMatch).toHaveBeenCalled();
},
{ timeout: 5000 }
);
// Wait for state update
await waitFor(
() => {
expect(screen.getByTestId('selected-metric').textContent).toBe('metric1');
},
{ timeout: 5000 }
);
// Mock setup for deselection
jest.clearAllMocks();
// Click again to deselect
await user.click(screen.getByTestId('select-metric'));
// Wait for deselection to complete
await waitFor(
() => {
expect(screen.getByTestId('selected-metric').textContent).toBe('');
},
{ timeout: 5000 }
);
});
it('should select and deselect label keys on click', async () => {
const user = userEvent.setup();
renderWithProvider(<TestComponent />);
// Wait for initialization to complete
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
},
{ timeout: 5000 }
);
// Initially no label key is selected
expect(screen.getByTestId('selected-label-keys').textContent).toBe('');
// Select a label key
await user.click(screen.getByTestId('select-label'));
// Wait for selection and API calls
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
expect.anything(),
'job',
undefined,
'MetricsBrowser_LV_job',
'40000'
);
},
{ timeout: 5000 }
);
// Wait for state update
await waitFor(
() => {
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
},
{ timeout: 5000 }
);
// Reset mocks for deselection test
jest.clearAllMocks();
// Click again to deselect
await user.click(screen.getByTestId('select-label'));
// Wait for state update
await waitFor(
() => {
expect(screen.getByTestId('selected-label-keys').textContent).toBe('');
},
{ timeout: 5000 }
);
// Verify localStorage update
expect(localStorageMock.setItem).toHaveBeenCalledWith(LAST_USED_LABELS_KEY, JSON.stringify([]));
});
it('should select and deselect label values on click', async () => {
const user = userEvent.setup();
renderWithProvider(<TestComponent />);
// Wait for initialization
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
},
{ timeout: 5000 }
);
// First select the label key to enable label value selection
await user.click(screen.getByTestId('select-label'));
// Wait for label key selection to complete
await waitFor(
() => {
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
},
{ timeout: 5000 }
);
jest.clearAllMocks();
// Now select a label value
await user.click(screen.getByTestId('select-label-value'));
// Wait for API calls after value selection
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
},
{ timeout: 5000 }
);
// Wait for selector update in UI
await waitFor(
() => {
expect(screen.getByTestId('selector').textContent).toBe('{job="grafana"}');
},
{ timeout: 5000 }
);
});
it('should fetch label values with the current selector', async () => {
const user = userEvent.setup();
renderWithProvider(<TestComponent />);
// Wait for initialization to complete
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
},
{ timeout: 5000 }
);
// First select a metric to create a non-empty selector
await user.click(screen.getByTestId('select-metric'));
// Wait for metric selection to complete
await waitFor(
() => {
expect(screen.getByTestId('selected-metric').textContent).toBe('metric1');
},
{ timeout: 5000 }
);
jest.clearAllMocks();
// Then select a label
await user.click(screen.getByTestId('select-label'));
// Wait for API call with the expected selector
await waitFor(
() => {
// Should call fetchSeriesValuesWithMatch with the current selector
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalledWith(
expect.anything(),
'job',
expect.stringContaining('metric1'),
expect.any(String),
'40000'
);
},
{ timeout: 5000 }
);
});
it('should clear all selections when clear is clicked', async () => {
const user = userEvent.setup();
renderWithProvider(<TestComponent />);
// Wait for initialization
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
},
{ timeout: 5000 }
);
// Set up some selections
await user.click(screen.getByTestId('select-metric'));
// Wait for metric selection
await waitFor(
() => {
expect(screen.getByTestId('selected-metric').textContent).toBe('metric1');
},
{ timeout: 5000 }
);
await user.click(screen.getByTestId('select-label'));
// Wait for label selection
await waitFor(
() => {
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
},
{ timeout: 5000 }
);
// Reset mocks before clear
jest.clearAllMocks();
// Clear selections
await user.click(screen.getByTestId('clear'));
// Wait for clear to trigger API calls
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
},
{ timeout: 5000 }
);
// Verify everything is cleared
await waitFor(
() => {
expect(screen.getByTestId('selected-metric').textContent).toBe('');
expect(screen.getByTestId('selected-label-keys').textContent).toBe('');
expect(screen.getByTestId('selector').textContent).toBe('{}');
},
{ timeout: 5000 }
);
});
it('should validate selectors and show status', async () => {
const user = userEvent.setup();
renderWithProvider(<TestComponent />);
// Wait for initialization
await waitFor(
() => {
expect(mockLanguageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled();
},
{ timeout: 5000 }
);
// Select a label to enable value selection
await user.click(screen.getByTestId('select-label'));
// Wait for label selection
await waitFor(
() => {
expect(screen.getByTestId('selected-label-keys').textContent).toBe('job');
},
{ timeout: 5000 }
);
// Select a value to get a valid selector
await user.click(screen.getByTestId('select-label-value'));
// Wait for the value selection
await waitFor(
() => {
expect(screen.getByTestId('selector').textContent).toBe('{job="grafana"}');
},
{ timeout: 5000 }
);
jest.clearAllMocks();
// Trigger validation
await user.click(screen.getByTestId('validate'));
// Wait for validation API call
await waitFor(
() => {
expect(mockLanguageProvider.fetchLabelsWithMatch).toHaveBeenCalledWith(expect.anything(), '{job="grafana"}');
},
{ timeout: 5000 }
);
// Wait for validation status to update
await waitFor(
() => {
expect(screen.getByTestId('validation-status').textContent).toContain('Selector is valid');
},
{ timeout: 5000 }
);
});
it('should throw error when hook is used outside provider', () => {
// Suppress console.error for this test
jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useMetricsBrowser());
}).toThrow('useMetricsBrowser must be used within a MetricsBrowserProvider');
// Restore console.error
(console.error as jest.Mock).mockRestore();
});
});
@@ -0,0 +1,155 @@
import { createContext, PropsWithChildren, useCallback, useContext, useMemo } from 'react';
import { TimeRange } from '@grafana/data';
import PromQlLanguageProvider from '../../language_provider';
import { buildSelector } from './selectorBuilder';
import { Metric } from './types';
import { useMetricsLabelsValues } from './useMetricsLabelsValues';
/**
* Context for the Metrics Browser component
* Provides state and handlers for browsing and selecting Prometheus metrics and labels
*/
interface MetricsBrowserContextType {
// Error and status state
err: string;
setErr: (err: string) => void;
status: string;
setStatus: (status: string) => void;
// Series limit settings
seriesLimit: string;
setSeriesLimit: (limit: string) => void;
// Callback when selector changes
onChange: (selector: string) => void;
// Data and selection state
metrics: Metric[];
labelKeys: string[];
labelValues: Record<string, string[]>;
selectedMetric: string;
selectedLabelKeys: string[];
selectedLabelValues: Record<string, string[]>;
// Event handlers
onMetricClick: (name: string) => void;
onLabelKeyClick: (name: string) => void;
onLabelValueClick: (labelKey: string, labelValue: string, isSelected: boolean) => void;
getSelector: () => string;
onClearClick: () => void;
// Validation
validationStatus: string;
onValidationClick: () => void;
}
const MetricsBrowserContext = createContext<MetricsBrowserContextType | undefined>(undefined);
type MetricsBrowserProviderProps = {
timeRange: TimeRange;
languageProvider: PromQlLanguageProvider;
onChange: (selector: string) => void;
};
/**
* Provider component for the Metrics Browser context
* Manages state and data fetching for metrics, labels, and values
*/
export function MetricsBrowserProvider({
children,
timeRange,
languageProvider,
onChange,
}: PropsWithChildren<MetricsBrowserProviderProps>) {
const {
err,
setErr,
status,
setStatus,
seriesLimit,
setSeriesLimit,
validationStatus,
metrics,
labelKeys,
labelValues,
selectedMetric,
selectedLabelKeys,
selectedLabelValues,
handleSelectedMetricChange,
handleSelectedLabelKeyChange,
handleSelectedLabelValueChange,
handleValidation,
handleClear,
} = useMetricsLabelsValues(timeRange, languageProvider);
// Build a Prometheus selector string from the current selections
const getSelector = useCallback(
() => buildSelector(selectedMetric, selectedLabelValues),
[selectedLabelValues, selectedMetric]
);
// Memoize the context value to prevent unnecessary re-renders
const value = useMemo(
() => ({
err,
setErr,
status,
setStatus,
seriesLimit,
setSeriesLimit,
validationStatus,
onChange,
getSelector,
metrics,
labelKeys,
labelValues,
selectedMetric,
selectedLabelKeys,
selectedLabelValues,
onMetricClick: handleSelectedMetricChange,
onLabelKeyClick: handleSelectedLabelKeyChange,
onLabelValueClick: handleSelectedLabelValueChange,
onValidationClick: handleValidation,
onClearClick: handleClear,
}),
[
err,
setErr,
status,
setStatus,
seriesLimit,
setSeriesLimit,
validationStatus,
onChange,
metrics,
getSelector,
labelKeys,
labelValues,
selectedMetric,
selectedLabelKeys,
selectedLabelValues,
handleSelectedLabelKeyChange,
handleSelectedLabelValueChange,
handleSelectedMetricChange,
handleValidation,
handleClear,
]
);
return <MetricsBrowserContext.Provider value={value}>{children}</MetricsBrowserContext.Provider>;
}
/**
* Hook to access the MetricsBrowser context
* Must be used within a MetricsBrowserProvider
*/
export function useMetricsBrowser() {
const context = useContext(MetricsBrowserContext);
if (context === undefined) {
throw new Error('useMetricsBrowser must be used within a MetricsBrowserProvider');
}
return context;
}
@@ -1,232 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PrometheusMetricsBrowser.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createTheme, getDefaultTimeRange, TimeRange } from '@grafana/data';
import PromQlLanguageProvider from '../../language_provider';
import { UnthemedPrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
import { BrowserProps } from './types';
describe('PrometheusMetricsBrowser', () => {
const setupProps = (): BrowserProps => {
const mockLanguageProvider = {
start: () => Promise.resolve(),
getLabelValues: (timeRange: TimeRange, name: string) => {
switch (name) {
case 'label1':
return ['value1-1', 'value1-2'];
case 'label2':
return ['value2-1', 'value2-2'];
case 'label3':
return ['value3-1', 'value3-2'];
}
return [];
},
// This must always call the series endpoint
// until we refactor all of the metrics browser
// to never use the series endpoint.
// The metrics browser expects both label names and label values.
// The labels endpoint with match does not supply label values
// and so using it breaks the metrics browser.
fetchSeriesLabels: (timeRange: TimeRange, selector: string) => {
switch (selector) {
case '{label1="value1-1"}':
return { label1: ['value1-1'], label2: ['value2-1'], label3: ['value3-1'] };
case '{label1=~"value1-1|value1-2"}':
return { label1: ['value1-1', 'value1-2'], label2: ['value2-1'], label3: ['value3-1', 'value3-2'] };
}
// Allow full set by default
return {
label1: ['value1-1', 'value1-2'],
label2: ['value2-1', 'value2-2'],
};
},
getLabelKeys: () => ['label1', 'label2', 'label3'],
};
const defaults: BrowserProps = {
theme: createTheme({ colors: { mode: 'dark' } }),
onChange: () => {},
autoSelect: 0,
languageProvider: mockLanguageProvider as unknown as PromQlLanguageProvider,
timeRange: getDefaultTimeRange(),
};
return defaults;
};
// Clear label selection manually because it's saved in localStorage
afterEach(async () => {
const clearBtn = screen.getByLabelText('Selector clear button');
await userEvent.click(clearBtn);
});
it('renders and loader shows when empty, and then first set of labels', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Loading appears and dissappears
screen.getByText(/Loading labels/);
await waitFor(() => {
expect(screen.queryByText(/Loading labels/)).not.toBeInTheDocument();
});
// Initial set of labels is available and not selected
expect(screen.queryByRole('option', { name: 'label1' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'label1', selected: true })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'label2' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'label2', selected: true })).not.toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
});
it('allows label and value selection/deselection', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2
const label2 = await screen.findByRole('option', { name: 'label2', selected: false });
expect(screen.queryByRole('list', { name: /Values/ })).not.toBeInTheDocument();
await userEvent.click(label2);
expect(screen.queryByRole('option', { name: 'label2', selected: true })).toBeInTheDocument();
// List of values for label2 appears
expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2');
expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
// Selecting label1, list for its values appears
const label1 = await screen.findByRole('option', { name: 'label1', selected: false });
await userEvent.click(label1);
expect(screen.queryByRole('option', { name: 'label1', selected: true })).toBeInTheDocument();
await screen.findByLabelText('Values for label1');
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2);
// Selecting value2-2 of label2
const value = await screen.findByRole('option', { name: 'value2-2', selected: false });
await userEvent.click(value);
await screen.findByRole('option', { name: 'value2-2', selected: true });
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-2"}');
// Selecting value2-1 of label2, both values now selected
const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false });
await userEvent.click(value2);
// await screen.findByRole('option', {name: 'value2-1', selected: true});
await screen.findByText('{label2=~"value2-1|value2-2"}');
// Deselecting value2-2, one value should remain
const selectedValue = await screen.findByRole('option', { name: 'value2-2', selected: true });
await userEvent.click(selectedValue);
await screen.findByRole('option', { name: 'value2-1', selected: true });
await screen.findByRole('option', { name: 'value2-2', selected: false });
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}');
});
it('allows label selection from multiple labels', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2
const label2 = await screen.findByRole('option', { name: 'label2', selected: false });
await userEvent.click(label2);
// List of values for label2 appears
expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2');
expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
// Selecting label1, list for its values appears
const label1 = await screen.findByRole('option', { name: 'label1', selected: false });
await userEvent.click(label1);
await screen.findByLabelText('Values for label1');
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2);
// Selecting value2-1 of label2
const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false });
await userEvent.click(value2);
await screen.findByText('{label2="value2-1"}');
// Selecting value from label1 for combined selector
const value1 = await screen.findByRole('option', { name: 'value1-2', selected: false });
await userEvent.click(value1);
await screen.findByRole('option', { name: 'value1-2', selected: true });
await screen.findByText('{label1="value1-2",label2="value2-1"}');
// Deselect label1 should remove label and value
const selectedLabel = (await screen.findAllByRole('option', { name: /label1/, selected: true }))[0];
await userEvent.click(selectedLabel);
await screen.findByRole('option', { name: /label1/, selected: false });
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(1);
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label2="value2-1"}');
});
it('allows clearing the label selection', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2
const label2 = await screen.findByRole('option', { name: 'label2', selected: false });
await userEvent.click(label2);
// List of values for label2 appears
expect(screen.queryByLabelText(/Values for/)).toHaveTextContent('label2');
expect(screen.queryByRole('option', { name: 'value2-1' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'value2-2' })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
// Selecting label1, list for its values appears
const label1 = await screen.findByRole('option', { name: 'label1', selected: false });
await userEvent.click(label1);
await screen.findByLabelText('Values for label1');
expect(await screen.findAllByRole('list', { name: /Values/ })).toHaveLength(2);
// Selecting value2-1 of label2
const value2 = await screen.findByRole('option', { name: 'value2-1', selected: false });
await userEvent.click(value2);
await screen.findByText('{label2="value2-1"}');
// Clear selector
const clearBtn = screen.getByLabelText('Selector clear button');
await userEvent.click(clearBtn);
await screen.findByRole('option', { name: 'label2', selected: false });
expect(screen.queryByLabelText('selector')).toHaveTextContent('{}');
});
it('filters values by input text', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2 and label1
const label2 = await screen.findByRole('option', { name: /label2/, selected: false });
await userEvent.click(label2);
const label1 = await screen.findByRole('option', { name: /label1/, selected: false });
await userEvent.click(label1);
await screen.findByLabelText('Values for label1');
await screen.findByLabelText('Values for label2');
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4);
// Typing '1' to filter for values
await userEvent.type(screen.getByLabelText('Filter expression for label values'), '1');
expect(screen.getByLabelText('Filter expression for label values')).toHaveValue('1');
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(3);
expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument();
});
it('facets labels', async () => {
const props = setupProps();
render(<UnthemedPrometheusMetricsBrowser {...props} />);
// Selecting label2 and label1
const label2 = await screen.findByRole('option', { name: /label2/, selected: false });
await userEvent.click(label2);
const label1 = await screen.findByRole('option', { name: /label1/, selected: false });
await userEvent.click(label1);
await screen.findByLabelText('Values for label1');
await screen.findByLabelText('Values for label2');
expect(await screen.findAllByRole('option', { name: /value/ })).toHaveLength(4);
expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3');
// Click value1-1 which triggers facetting for value3-x, and still show all value1-x
const value1 = await screen.findByRole('option', { name: 'value1-1', selected: false });
await userEvent.click(value1);
await waitFor(() => expect(screen.queryByRole('option', { name: 'value2-2' })).not.toBeInTheDocument());
expect(screen.queryByRole('option', { name: 'value1-2' })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1="value1-1"}');
expect(screen.queryByRole('option', { name: /label3/ })).toHaveTextContent('label3 (1)');
// Click value1-2 for which facetting will allow more values for value3-x
const value12 = await screen.findByRole('option', { name: 'value1-2', selected: false });
await userEvent.click(value12);
await screen.findByRole('option', { name: 'value1-2', selected: true });
await screen.findByRole('option', { name: /label3/, selected: false });
await userEvent.click(screen.getByRole('option', { name: /label3/ }));
await screen.findByLabelText('Values for label3');
expect(screen.queryByRole('option', { name: 'value1-1', selected: true })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'value1-2', selected: true })).toBeInTheDocument();
expect(screen.queryByLabelText('selector')).toHaveTextContent('{label1=~"value1-1|value1-2"}');
expect(screen.queryAllByRole('option', { name: /label3/ })[0]).toHaveTextContent('label3 (2)');
});
});
@@ -1,373 +0,0 @@
// 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<BrowserProps, BrowserState> {
state: BrowserState = {
labels: [],
labelSearchTerm: '',
metricSearchTerm: '',
status: 'Ready',
error: '',
validationStatus: '',
valueSearchTerm: '',
};
onChangeLabelSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ labelSearchTerm: event.target.value });
};
onChangeMetricSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ metricSearchTerm: event.target.value });
};
onChangeSeriesLimit = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ seriesLimit: event.target.value.trim() });
};
onChangeValueSearch = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ valueSearchTerm: event.target.value });
};
onClickRunQuery = () => {
const selector = buildSelector(this.state.labels);
this.props.onChange(selector);
};
onClickRunRateQuery = () => {
const selector = buildSelector(this.state.labels);
const query = `rate(${selector}[$__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<HTMLElement>) => {
const label = this.state.labels.find((l) => l.name === name);
if (!label) {
return;
}
// Toggle selected state
const selected = !label.selected;
let nextValue: Partial<SelectableLabel> = { selected };
if (label.values && !selected) {
// Deselect all values if label was deselected
const values = label.values.map((value) => ({ ...value, selected: false }));
nextValue = { ...nextValue, facets: 0, values };
}
// Resetting search to prevent empty results
this.setState({ labelSearchTerm: '' });
this.updateLabelState(name, nextValue, '', () => this.doFacettingForLabel(name));
};
onClickValue = (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => {
const label = this.state.labels.find((l) => l.name === name);
if (!label || !label.values) {
return;
}
// Resetting search to prevent empty results
this.setState({ labelSearchTerm: '' });
// Toggling value for selected label, leaving other values intact
const values = label.values.map((v) => ({ ...v, selected: v.name === value ? !v.selected : v.selected }));
this.updateLabelState(name, { values }, '', () => this.doFacetting(name));
};
onClickMetric = (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => {
// Finding special metric label
const label = this.state.labels.find((l) => l.name === name);
if (!label || !label.values) {
return;
}
// Resetting search to prevent empty results
this.setState({ metricSearchTerm: '' });
// Toggling value for selected label, leaving other values intact
const values = label.values.map((v) => ({
...v,
selected: v.name === value || v.selected ? !v.selected : v.selected,
}));
// Toggle selected state of special metrics label
const selected = values.some((v) => v.selected);
this.updateLabelState(name, { selected, values }, '', () => this.doFacetting(name));
};
onClickValidate = () => {
const selector = buildSelector(this.state.labels);
this.validateSelector(selector);
};
updateLabelState(name: string, updatedFields: Partial<SelectableLabel>, status = '', cb?: () => void) {
this.setState((state) => {
const labels: SelectableLabel[] = state.labels.map((label) => {
if (label.name === name) {
return { ...label, ...updatedFields };
}
return label;
});
// New status overrides errors
const error = status ? '' : state.error;
return { labels, status, error, validationStatus: '' };
}, cb);
}
componentDidMount() {
const { languageProvider } = this.props;
if (languageProvider) {
const selectedLabels: string[] = 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 (
<div className={styles.wrapper}>
<LoadingPlaceholder text="Loading labels..." />
</div>
);
}
// 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 (
<div className={styles.wrapper}>
<Stack gap={3}>
<MetricSelector
metrics={metrics}
metricSearchTerm={metricSearchTerm}
seriesLimit={this.state.seriesLimit ?? DEFAULT_SERIES_LIMIT}
onChangeMetricSearch={this.onChangeMetricSearch}
onChangeSeriesLimit={this.onChangeSeriesLimit}
onClickMetric={this.onClickMetric}
styles={styles}
/>
<div>
<LabelSelector
nonMetricLabels={nonMetricLabels}
labelSearchTerm={labelSearchTerm}
onChangeLabelSearch={this.onChangeLabelSearch}
onClickLabel={this.onClickLabel}
styles={styles}
/>
<ValueSelector
selectedLabels={selectedLabels}
valueSearchTerm={valueSearchTerm}
onChangeValueSearch={this.onChangeValueSearch}
onClickValue={this.onClickValue}
onClickLabel={this.onClickLabel}
styles={styles}
/>
</div>
</Stack>
<SelectorActions
selector={selector}
validationStatus={validationStatus}
status={status}
error={error}
empty={empty}
onClickRunQuery={this.onClickRunQuery}
onClickRunRateQuery={this.onClickRunRateQuery}
onClickValidate={this.onClickValidate}
onClickClear={this.onClickClear}
styles={styles}
/>
</div>
);
}
}
export const PrometheusMetricsBrowser = withTheme2(UnthemedPrometheusMetricsBrowser);
@@ -1,33 +1,30 @@
import { cx } from '@emotion/css';
import { useMemo } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Button, Label, Stack } from '@grafana/ui';
import { Button, Label, Stack, useStyles2 } 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<string, string>;
}
import { useMetricsBrowser } from './MetricsBrowserContext';
import { getStylesSelectorActions } from './styles';
import { EMPTY_SELECTOR } from './types';
export function SelectorActions() {
const styles = useStyles2(getStylesSelectorActions);
const { validationStatus, onValidationClick, getSelector, onChange, status, err, onClearClick } = useMetricsBrowser();
const selector = getSelector();
const onClickRunQuery = () => {
onChange(selector);
};
const onClickRunRateQuery = () => {
const query = `rate(${selector}[$__rate_interval])`;
onChange(query);
};
const empty = useMemo(() => selector === EMPTY_SELECTOR, [selector]);
export function SelectorActions({
selector,
validationStatus,
status,
error,
empty,
onClickRunQuery,
onClickRunRateQuery,
onClickValidate,
onClickClear,
styles,
}: SelectorActionsProps) {
return (
<div className={styles.section}>
<Label>4. Resulting selector</Label>
@@ -58,7 +55,7 @@ export function SelectorActions({
aria-label="Validate submit button"
variant="secondary"
disabled={empty}
onClick={onClickValidate}
onClick={onValidationClick}
>
Validate selector
</Button>
@@ -66,12 +63,12 @@ export function SelectorActions({
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.clear}
aria-label="Selector clear button"
variant="secondary"
onClick={onClickClear}
onClick={onClearClick}
>
Clear
</Button>
<div className={cx(styles.status, (status || error) && styles.statusShowing)}>
<span className={error ? styles.error : ''}>{error || status}</span>
<div className={cx(styles.status, (status || err) && styles.statusShowing)}>
<span className={err ? styles.error : ''}>{err || status}</span>
</div>
</Stack>
</div>
@@ -1,28 +1,18 @@
import { ChangeEvent, MouseEvent } from 'react';
import { useState } from 'react';
import { FixedSizeList } from 'react-window';
import { selectors } from '@grafana/e2e-selectors';
import { Input, Label, BrowserLabel as PromLabel } from '@grafana/ui';
import { BrowserLabel as PromLabel, Input, Label, useStyles2 } from '@grafana/ui';
import { SelectableLabel, LIST_ITEM_SIZE } from './types';
import { useMetricsBrowser } from './MetricsBrowserContext';
import { getStylesValueSelector } from './styles';
import { LIST_ITEM_SIZE } from './types';
interface ValueSelectorProps {
selectedLabels: SelectableLabel[];
valueSearchTerm: string;
onChangeValueSearch: (event: ChangeEvent<HTMLInputElement>) => void;
onClickValue: (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => void;
onClickLabel: (name: string, value: string | undefined, event: MouseEvent<HTMLElement>) => void;
styles: Record<string, string>;
}
export function ValueSelector() {
const styles = useStyles2(getStylesValueSelector);
const [valueSearchTerm, setValueSearchTerm] = useState('');
const { labelValues, selectedLabelValues, onLabelValueClick, onLabelKeyClick } = useMetricsBrowser();
export function ValueSelector({
selectedLabels,
valueSearchTerm,
onChangeValueSearch,
onClickValue,
onClickLabel,
styles,
}: ValueSelectorProps) {
return (
<div className={styles.section}>
<Label description="Use the search field to find values across selected labels.">
@@ -30,54 +20,57 @@ export function ValueSelector({
</Label>
<div>
<Input
onChange={onChangeValueSearch}
onChange={(e) => setValueSearchTerm(e.currentTarget.value)}
aria-label="Filter expression for label values"
value={valueSearchTerm}
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.labelValuesFilter}
/>
</div>
<div className={styles.valueListArea}>
{selectedLabels.map((label) => (
<div role="list" key={label.name} aria-label={`Values for ${label.name}`} className={styles.valueListWrapper}>
<div className={styles.valueTitle}>
<PromLabel
name={label.name}
loading={label.loading}
active={label.selected}
hidden={label.hidden}
// If no facets, we want to show number of all label values
facets={label.facets || label.values?.length}
onClick={onClickLabel}
/>
{Object.entries(labelValues).map(([lk, lv]) => {
if (!lk || !lv) {
console.error('label values are empty:', { lk, lv });
return null;
}
return (
<div role="list" key={lk} aria-label={`Values for ${lk}`} className={styles.valueListWrapper}>
<div className={styles.valueTitle}>
<PromLabel
name={lk}
loading={false}
active={true}
hidden={false}
facets={lv.length}
onClick={onLabelKeyClick}
/>
</div>
<FixedSizeList
height={Math.min(200, LIST_ITEM_SIZE * (lv.length || 0))}
itemCount={lv.length || 0}
itemSize={28}
itemKey={(i) => lv[i]}
width={200}
className={styles.valueList}
>
{({ index, style }) => {
const value = lv[index];
const isSelected = selectedLabelValues[lk]?.includes(value);
return (
<div style={style}>
<PromLabel
name={value}
value={value}
active={isSelected}
onClick={(name) => onLabelValueClick(lk, name, !isSelected)}
searchTerm={valueSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
<FixedSizeList
height={Math.min(200, LIST_ITEM_SIZE * (label.values?.length || 0))}
itemCount={label.values?.length || 0}
itemSize={28}
itemKey={(i) => label.values![i].name}
width={200}
className={styles.valueList}
>
{({ index, style }) => {
const value = label.values?.[index];
if (!value) {
return null;
}
return (
<div style={style}>
<PromLabel
name={label.name}
value={value?.name}
active={value?.selected}
onClick={onClickValue}
searchTerm={valueSearchTerm}
/>
</div>
);
}}
</FixedSizeList>
</div>
))}
);
})}
</div>
</div>
);
@@ -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=~"值|😀|你好"}');
});
});
});
});
@@ -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, string[]>): 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<string, string[]>,
lastFacetted?: string
): SelectableLabel[] {
return labels.map((label) => {
const possibleValues = possibleLabels[label.name];
if (possibleValues) {
let existingValues: FacettableValue[];
if (label.name === lastFacetted && label.values) {
// Facetting this label, show all values
existingValues = label.values;
} else {
// Keep selection in other facets
const selectedValues: Set<string> = new Set(
label.values?.filter((value) => value.selected).map((value) => value.name) || []
);
// Values for this label have not been requested yet, let's use the facetted ones as the initial values
existingValues = possibleValues.map((value) => ({ name: value, selected: selectedValues.has(value) }));
}
return {
...label,
loading: false,
values: existingValues,
hidden: !possibleValues,
facets: existingValues.length,
};
}
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(',')}}`;
}
@@ -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',
}),
}));
});
@@ -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 = '{}';
@@ -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<string, string> = {};
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');
});
});
});
});
@@ -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>(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<Metric[]>([]);
const [selectedMetric, setSelectedMetric] = useState('');
const [labelKeys, setLabelKeys] = useState<string[]>([]);
const [selectedLabelKeys, setSelectedLabelKeys] = useState<string[]>([]);
const [lastSelectedLabelKey, setLastSelectedLabelKey] = useState('');
const [labelValues, setLabelValues] = useState<Record<string, string[]>>({});
const [selectedLabelValues, setSelectedLabelValues] = useState<Record<string, string[]>>({});
// 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<string, string[]>) => {
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<string, string[]> = {};
const newSelectedLabelValues: Record<string, string[]> = {};
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<string, string[]>) => {
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<string, string[]> = { ...labelValues };
const newSelectedLabelValues: Record<string, string[]> = { ...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<string, string[]> = {};
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,
};
};
+1 -1
View File
@@ -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';
@@ -199,8 +199,8 @@ export default class PromQlLanguageProvider extends LanguageProvider {
}
}
fetchLabelValues = async (range: TimeRange, key: string): Promise<string[]> => {
const params = this.datasource.getAdjustedInterval(range);
fetchLabelValues = async (range: TimeRange, key: string, limit?: string): Promise<string[]> => {
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<string[]> => {
fetchLabels = async (timeRange: TimeRange, queries?: PromQuery[], limit?: string): Promise<string[]> => {
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<string[]> => {
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<BackendSrvRequest> | 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<string[]> => {
let possibleLabelNames, data: Record<string, string[]>;
@@ -333,10 +328,11 @@ export default class PromQlLanguageProvider extends LanguageProvider {
fetchLabelsWithMatch = async (
timeRange: TimeRange,
name: string,
withName?: boolean
withName?: boolean,
withLimit?: string
): Promise<Record<string, string[]>> => {
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<Record<string, string[]>> => {
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,