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:
@@ -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>
|
||||
);
|
||||
};
|
||||
+507
@@ -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;
|
||||
}
|
||||
-232
@@ -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)');
|
||||
});
|
||||
});
|
||||
-373
@@ -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>
|
||||
);
|
||||
|
||||
+72
-407
@@ -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 = '{}';
|
||||
|
||||
+904
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user