Prometheus: Refactor metrics modal to handle high cardinality metrics (#108437)

* remove redux from PromQueryBuilderContainer

* remove export

* housekeeping

* introduce MetricsModalContext to replace redux

* handle result pagination

* add search related values in context

* handle filtering

* handle search

* handle search and highlighting

* cleaning up

* better helpers

* remove unused types

* reorganize the code

* update settings

* improve search and fetch

* remove unused styles

* linting

* fix tests

* update

* lint

* i18n

* import order

* try to fix the test

* remove start call from variable editor

* fix highlighter

* fix pagination alignment

* make the entire row clickable and remove select button

* review updates

* remove settings

* use ufuzzy search

* fix tests

* use case-insensitive search

* review fixes

* fix pagination

* improve readability
This commit is contained in:
ismail simsek
2025-07-31 23:32:43 +02:00
committed by GitHub
parent 925e19acf2
commit 025d8527dd
30 changed files with 1279 additions and 1337 deletions
@@ -24,11 +24,11 @@ export function createPromDS(dataSourceID: string, name: string): void {
}
export function getResources() {
cy.intercept(/__name__/g, metricResponse);
cy.intercept(/__name__/g, metricResponse).as('getMetricNames');
cy.intercept(/metadata/g, metadataResponse);
cy.intercept(/metadata/g, metadataResponse).as('getMetadata');
cy.intercept(/labels/g, labelsResponse);
cy.intercept(/labels/g, labelsResponse).as('getLabels');
}
const metricResponse = {
@@ -138,12 +138,11 @@ describe('Prometheus query editor', () => {
it('can select a metric and provide a hint', () => {
navigateToEditor('Builder', 'prometheusBuilder');
getResources();
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click().type('metric1');
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().should('exist').click();
cy.wait('@getMetadata');
e2e.components.DataSource.Prometheus.queryEditor.builder.metricSelect().type('metric1');
selectOption('metric1');
e2e.components.DataSource.Prometheus.queryEditor.builder.hints().contains('hint: add rate');
});
@@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { select } from 'react-select-event';
import { dateTime, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { PrometheusDatasource } from '../datasource';
@@ -376,24 +375,4 @@ describe('PromVariableQueryEditor', () => {
qryType: 5,
});
});
test('Calls language provider with the time range received in props', async () => {
const now = dateTime('2023-09-16T21:26:00Z');
const range: TimeRange = {
from: dateTime(now).subtract(2, 'days'),
to: now,
raw: {
from: 'now-2d',
to: 'now',
},
};
props.range = range;
const languageProviderStartMock = jest.fn();
props.datasource.languageProvider.start = languageProviderStartMock;
render(<PromVariableQueryEditor {...props} />);
expect(languageProviderStartMock).toHaveBeenCalledWith(range);
});
});
@@ -77,11 +77,6 @@ export const PromVariableQueryEditor = ({ onChange, query, datasource, range }:
// label filters have been added as a filter for metrics in label values query type
const [labelFilters, setLabelFilters] = useState<QueryBuilderLabelFilter[]>([]);
useEffect(() => {
datasource.languageProvider.start(range);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!query) {
return;
@@ -311,10 +311,6 @@
}
},
"querybuilder": {
"additional-settings": {
"content-filter-metric-names-regex-search-using": "Filter metric names by regex search, using an additional call on the Prometheus API.",
"disable-text-wrap": "Disable text wrap"
},
"feedback-link": {
"give-feedback": "Give feedback",
"title-give-feedback": "The metrics explorer is new, please let us know how we can improve it"
@@ -328,9 +324,6 @@
},
"get-placeholders": {
"browse": "Search metrics by name",
"include-null-metadata": "Include results with no metadata",
"metadata-search-switch": "Include description in search",
"set-use-backend": "Enable regex search",
"type": "Filter by type"
},
"get-prom-types": {
@@ -378,15 +371,9 @@
"tooltip-metric": "Optional: returns a list of label values for the label name in the specified metric."
},
"metrics-modal": {
"additional-settings": "Additional Settings",
"aria-label-additional-settings": "Additional settings",
"aria-label-browse-metrics": "Browse metrics",
"currently-selected": "Currently selected: {{selected}}",
"metrics-pre-filtered": "These metrics have been pre-filtered by labels chosen in the label filters.",
"placeholder-results-per-page": "results per page",
"results-amount_one": "Showing {{num}} of {{count}} results",
"results-amount_other": "Showing {{num}} of {{count}} results",
"results-per-page": "Results per page",
"title-metrics-explorer": "Metrics explorer"
},
"nested-query": {
@@ -500,7 +487,6 @@
"message-expand-search": "There are no metrics found. Try to expand your search and filters.",
"message-no-metrics-found": "There are no metrics found in the data source.",
"name": "Name",
"select": "Select",
"type": "Type"
},
"update-function-args": {
@@ -11,9 +11,8 @@ import { PrometheusDatasource } from '../../datasource';
import { QueryBuilderLabelFilter } from '../shared/types';
import { PromVisualQuery } from '../types';
import { formatKeyValueStrings } from './formatter';
import { MetricsModal } from './metrics-modal/MetricsModal';
import { tracking } from './metrics-modal/state/helpers';
import { formatKeyValueStrings } from './shared/formatter';
export interface MetricComboboxProps {
metricLookupDisabled: boolean;
@@ -76,18 +75,6 @@ export function MetricCombobox({
[getMetricLabels, onGetMetrics]
);
const loadMetricsExplorerMetrics = useCallback(async () => {
const allMetrics = await onGetMetrics();
const metrics: string[] = [];
for (const metric of allMetrics) {
if (metric.value) {
metrics.push(metric.value);
}
}
return metrics;
}, [onGetMetrics]);
const asyncSelect = () => {
return (
<InputGroup>
@@ -115,10 +102,7 @@ export function MetricCombobox({
)}
variant="secondary"
icon="book-open"
onClick={() => {
tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query);
setMetricsModalOpen(true);
}}
onClick={() => setMetricsModalOpen(true)}
/>
</InputGroup>
);
@@ -133,7 +117,6 @@ export function MetricCombobox({
onClose={() => setMetricsModalOpen(false)}
query={query}
onChange={onChange}
initialMetrics={loadMetricsExplorerMetrics}
timeRange={timeRange}
/>
)}
@@ -11,7 +11,7 @@ import { PrometheusDatasource } from '../../datasource';
import { binaryScalarDefs } from '../binaryScalarOperations';
import { PromVisualQueryBinary } from '../types';
import { QueryBuilderContent } from './shared/QueryBuilderContent';
import { QueryBuilderContent } from './QueryBuilderContent';
interface NestedQueryProps {
nestedQuery: PromVisualQueryBinary;
@@ -6,7 +6,8 @@ import { PanelData } from '@grafana/data';
import { PrometheusDatasource } from '../../datasource';
import { PromVisualQuery } from '../types';
import { BaseQueryBuilder } from './shared/BaseQueryBuilder';
import { NestedQueryList } from './NestedQueryList';
import { QueryBuilderContent } from './QueryBuilderContent';
interface PromQueryBuilderProps {
query: PromVisualQuery;
@@ -18,7 +19,22 @@ interface PromQueryBuilderProps {
}
export const PromQueryBuilder = memo<PromQueryBuilderProps>((props) => {
return <BaseQueryBuilder {...props} />;
const { query, datasource, onChange, onRunQuery, showExplain } = props;
return (
<>
<QueryBuilderContent {...props} />
{query.binaryQueries && query.binaryQueries.length > 0 && (
<NestedQueryList
query={query}
datasource={datasource}
onChange={onChange}
onRunQuery={onRunQuery}
showExplain={showExplain}
/>
)}
</>
);
});
PromQueryBuilder.displayName = 'PromQueryBuilder';
@@ -20,12 +20,8 @@ describe('PromQueryBuilderContainer', () => {
await addOperationInQueryBuilder('Range functions', 'Rate');
// extra fields here are for storing metrics explorer settings. Future work: store these in local storage.
expect(props.onChange).toHaveBeenCalledWith({
disableTextWrap: false,
expr: 'rate(metric_test{job="testjob"}[$__rate_interval])',
fullMetaSearch: false,
includeNullMetadata: true,
refId: 'A',
useBackend: false,
});
});
@@ -1,6 +1,5 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilderContainer.tsx
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { useEffect, useReducer } from 'react';
import { useEffect, useState } from 'react';
import { PanelData } from '@grafana/data';
@@ -12,7 +11,6 @@ import { PromVisualQuery } from '../types';
import { PromQueryBuilder } from './PromQueryBuilder';
import { QueryPreview } from './QueryPreview';
import { getSettings, MetricsModalSettings } from './metrics-modal/state/state';
interface PromQueryBuilderContainerProps {
query: PromQuery;
@@ -23,7 +21,7 @@ interface PromQueryBuilderContainerProps {
showExplain: boolean;
}
interface State {
interface RenderedQuery {
visQuery?: PromVisualQuery;
expr: string;
}
@@ -33,80 +31,38 @@ interface State {
*/
export function PromQueryBuilderContainer(props: PromQueryBuilderContainerProps) {
const { query, onChange, onRunQuery, datasource, data, showExplain } = props;
const [state, dispatch] = useReducer(stateSlice.reducer, { expr: query.expr });
// Only rebuild visual query if expr changes from outside
useEffect(() => {
dispatch(exprChanged(query.expr));
dispatch(
setMetricsModalSettings({
useBackend: query.useBackend ?? false,
disableTextWrap: query.disableTextWrap ?? false,
fullMetaSearch: query.fullMetaSearch ?? false,
includeNullMetadata: query.includeNullMetadata ?? true,
})
);
}, [query]);
const [rendered, setRendered] = useState<RenderedQuery>({ expr: query.expr });
useEffect(() => {
datasource.languageProvider.start(data?.timeRange);
}, [data?.timeRange, datasource.languageProvider]);
// Only rebuild visual query if expr changes from outside
if (!rendered.visQuery || rendered.expr !== query.expr) {
const parseResult = buildVisualQueryFromString(query.expr ?? '');
setRendered({ expr: query.expr, visQuery: parseResult.query });
}
}, [query, rendered]);
const onVisQueryChange = (visQuery: PromVisualQuery) => {
const expr = promQueryModeller.renderQuery(visQuery);
dispatch(visualQueryChange({ visQuery, expr }));
const metricsModalSettings = getSettings(visQuery);
onChange({ ...props.query, expr: expr, ...metricsModalSettings });
setRendered({ expr, visQuery });
onChange({ ...props.query, expr });
};
if (!state.visQuery) {
if (!rendered.visQuery) {
return null;
}
return (
<>
<PromQueryBuilder
query={state.visQuery}
datasource={datasource}
onChange={onVisQueryChange}
onRunQuery={onRunQuery}
data={data}
datasource={datasource}
showExplain={showExplain}
query={rendered.visQuery}
onRunQuery={onRunQuery}
onChange={onVisQueryChange}
/>
{<QueryPreview query={query.expr} />}
<QueryPreview query={query.expr} />
</>
);
}
const initialState: State = {
expr: '',
};
const stateSlice = createSlice({
name: 'prom-builder-container',
initialState,
reducers: {
visualQueryChange: (state, action: PayloadAction<{ visQuery: PromVisualQuery; expr: string }>) => {
state.expr = action.payload.expr;
state.visQuery = action.payload.visQuery;
},
exprChanged: (state, action: PayloadAction<string>) => {
if (!state.visQuery || state.expr !== action.payload) {
state.expr = action.payload;
const parseResult = buildVisualQueryFromString(action.payload ?? '');
state.visQuery = parseResult.query;
}
},
setMetricsModalSettings: (state, action: PayloadAction<MetricsModalSettings>) => {
if (state.visQuery) {
state.visQuery.useBackend = action.payload.useBackend;
state.visQuery.disableTextWrap = action.payload.disableTextWrap;
state.visQuery.fullMetaSearch = action.payload.fullMetaSearch;
state.visQuery.includeNullMetadata = action.payload.includeNullMetadata;
}
},
},
});
const { visualQueryChange, exprChanged, setMetricsModalSettings } = stateSlice.actions;
@@ -1,28 +1,37 @@
import { css } from '@emotion/css';
import { memo, useState } from 'react';
import { DataSourceApi, getDefaultTimeRange } from '@grafana/data';
import { DataSourceApi, getDefaultTimeRange, PanelData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { EditorRow } from '@grafana/plugin-ui';
import { promqlGrammar } from '../../../promql';
import { getInitHints } from '../../../query_hints';
import { buildVisualQueryFromString } from '../../parsing';
import { OperationExplainedBox } from '../../shared/OperationExplainedBox';
import { OperationList } from '../../shared/OperationList';
import { OperationListExplained } from '../../shared/OperationListExplained';
import { OperationsEditorRow } from '../../shared/OperationsEditorRow';
import { QueryBuilderHints } from '../../shared/QueryBuilderHints';
import { RawQuery } from '../../shared/RawQuery';
import { promQueryModeller } from '../../shared/modeller_instance';
import { QueryBuilderOperation } from '../../shared/types';
import { PromVisualQuery } from '../../types';
import { MetricsLabelsSection } from '../MetricsLabelsSection';
import { EXPLAIN_LABEL_FILTER_CONTENT } from '../PromQueryBuilderExplained';
import { PrometheusDatasource } from '../../datasource';
import { promqlGrammar } from '../../promql';
import { getInitHints } from '../../query_hints';
import { buildVisualQueryFromString } from '../parsing';
import { OperationExplainedBox } from '../shared/OperationExplainedBox';
import { OperationList } from '../shared/OperationList';
import { OperationListExplained } from '../shared/OperationListExplained';
import { OperationsEditorRow } from '../shared/OperationsEditorRow';
import { QueryBuilderHints } from '../shared/QueryBuilderHints';
import { RawQuery } from '../shared/RawQuery';
import { promQueryModeller } from '../shared/modeller_instance';
import { QueryBuilderOperation } from '../shared/types';
import { PromVisualQuery } from '../types';
import { BaseQueryBuilderProps } from './types';
import { MetricsLabelsSection } from './MetricsLabelsSection';
import { EXPLAIN_LABEL_FILTER_CONTENT } from './PromQueryBuilderExplained';
export const QueryBuilderContent = memo<BaseQueryBuilderProps>((props) => {
interface QueryBuilderContentProps {
query: PromVisualQuery;
datasource: PrometheusDatasource;
onChange: (update: PromVisualQuery) => void;
onRunQuery: () => void;
data?: PanelData;
showExplain: boolean;
}
export const QueryBuilderContent = memo<QueryBuilderContentProps>((props) => {
const { datasource, query, onChange, onRunQuery, data, showExplain } = props;
const [highlightedOp, setHighlightedOp] = useState<QueryBuilderOperation | undefined>();
@@ -1,7 +1,7 @@
import { regexifyLabelValuesQueryString } from '../../parsingUtils';
import { QueryBuilderLabelFilter } from '../../shared/types';
import { regexifyLabelValuesQueryString } from '../parsingUtils';
import { QueryBuilderLabelFilter } from '../shared/types';
export const formatPrometheusLabelFiltersToString = (
const formatPrometheusLabelFiltersToString = (
queryString: string,
labelsFilters: QueryBuilderLabelFilter[] | undefined
): string => {
@@ -1,87 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/AdditionalSettings.tsx
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Icon, Switch, Tooltip, useTheme2 } from '@grafana/ui';
import { metricsModaltestIds } from './shared/testIds';
import { AdditionalSettingsProps } from './shared/types';
import { getPlaceholders } from './state/helpers';
export function AdditionalSettings(props: AdditionalSettingsProps) {
const { state, onChangeFullMetaSearch, onChangeIncludeNullMetadata, onChangeDisableTextWrap, onChangeUseBackend } =
props;
const theme = useTheme2();
const styles = getStyles(theme);
const placeholders = getPlaceholders();
return (
<>
<div className={styles.selectItem}>
<Switch
data-testid={metricsModaltestIds.searchWithMetadata}
value={state.fullMetaSearch}
disabled={state.useBackend || !state.hasMetadata}
onChange={() => onChangeFullMetaSearch()}
/>
<div className={styles.selectItemLabel}>{placeholders.metadataSearchSwitch}</div>
</div>
<div className={styles.selectItem}>
<Switch
value={state.includeNullMetadata}
disabled={!state.hasMetadata}
onChange={() => onChangeIncludeNullMetadata()}
/>
<div className={styles.selectItemLabel}>{placeholders.includeNullMetadata}</div>
</div>
<div className={styles.selectItem}>
<Switch value={state.disableTextWrap} onChange={() => onChangeDisableTextWrap()} />
<div className={styles.selectItemLabel}>
<Trans i18nKey="grafana-prometheus.querybuilder.additional-settings.disable-text-wrap">
Disable text wrap
</Trans>
</div>
</div>
<div className={styles.selectItem}>
<Switch
data-testid={metricsModaltestIds.setUseBackend}
value={state.useBackend}
onChange={() => onChangeUseBackend()}
/>
<div className={styles.selectItemLabel}>{placeholders.setUseBackend}&nbsp;</div>
<Tooltip
content={t(
'grafana-prometheus.querybuilder.additional-settings.content-filter-metric-names-regex-search-using',
'Filter metric names by regex search, using an additional call on the Prometheus API.'
)}
placement="bottom-end"
>
<Icon name="info-circle" size="xs" className={styles.settingsIcon} />
</Tooltip>
</div>
</>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
settingsIcon: css({
color: theme.colors.text.secondary,
}),
selectItem: css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
padding: '4px 0',
}),
selectItemLabel: css({
margin: `0 0 0 ${theme.spacing(1)}`,
alignSelf: 'center',
color: theme.colors.text.secondary,
fontSize: '12px',
}),
};
}
@@ -12,7 +12,7 @@ import { PromOptions } from '../../../types';
import { PromVisualQuery } from '../../types';
import { MetricsModal } from './MetricsModal';
import { metricsModaltestIds } from './shared/testIds';
import { metricsModaltestIds } from './testIds';
// don't care about interaction tracking in our unit tests
jest.mock('@grafana/runtime', () => ({
@@ -117,28 +117,6 @@ describe('MetricsModal', () => {
});
});
it('shows results metrics per page chosen by the user', async () => {
setup(defaultQuery, listOfMetrics);
const resultsPerPageInput = screen.getByTestId(metricsModaltestIds.resultsPerPage);
await userEvent.type(resultsPerPageInput, '12');
const metricInsideRange = screen.getByText('j');
expect(metricInsideRange).toBeInTheDocument();
});
it('paginates lots of metrics and does not run out of memory', async () => {
const lotsOfMetrics: string[] = [...Array(100000).keys()].map((i) => '' + i);
setup(defaultQuery, lotsOfMetrics);
await waitFor(() => {
// doesn't break on loading
expect(screen.getByText('0')).toBeInTheDocument();
});
const resultsPerPageInput = screen.getByTestId(metricsModaltestIds.resultsPerPage);
// doesn't break on changing results per page
await userEvent.type(resultsPerPageInput, '11');
const metricInsideRange = screen.getByText('9');
expect(metricInsideRange).toBeInTheDocument();
});
// Fuzzy search
it('searches and filter by metric name with a fuzzy search', async () => {
// search for a_bucket by name
@@ -171,14 +149,6 @@ describe('MetricsModal', () => {
expect(metricABucket).toBeInTheDocument();
});
const showSettingsButton = screen.getByTestId(metricsModaltestIds.showAdditionalSettings);
expect(showSettingsButton).toBeInTheDocument();
await userEvent.click(showSettingsButton);
const metadataSwitch = screen.getByTestId(metricsModaltestIds.searchWithMetadata);
expect(metadataSwitch).toBeInTheDocument();
await userEvent.click(metadataSwitch);
const searchMetric = screen.getByTestId(metricsModaltestIds.searchMetric);
expect(searchMetric).toBeInTheDocument();
await userEvent.type(searchMetric, 'functions');
@@ -248,6 +218,34 @@ function createDatasource(withLabels?: boolean) {
const languageProvider = new EmptyLanguageProviderMock() as unknown as PrometheusLanguageProviderInterface;
// display different results if their labels are selected in the PromVisualQuery
if (withLabels) {
languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({
'with-labels': {
type: 'with-labels-type',
help: 'with-labels-help',
},
});
} else {
// all metrics - create metadata for all metrics in listOfMetrics
const mockMetadata: Record<string, { type: string; help: string }> = {};
listOfMetrics.forEach((metric) => {
if (metric === 'all-metrics') {
mockMetadata[metric] = { type: 'all-metrics-type', help: 'all-metrics-help' };
} else if (metric === 'a_bucket') {
mockMetadata[metric] = { type: 'histogram', help: 'for functions' };
} else if (metric === 'new_histogram') {
mockMetadata[metric] = { type: 'histogram', help: 'a native histogram' };
} else if (metric === 'a') {
mockMetadata[metric] = { type: 'counter', help: 'a-metric-help' };
} else {
mockMetadata[metric] = { type: 'counter', help: `${metric} metric help` };
}
});
languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue(mockMetadata);
}
// Also mock the retrieveMetricsMetadata method that might be used elsewhere
if (withLabels) {
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({
'with-labels': {
@@ -256,26 +254,23 @@ function createDatasource(withLabels?: boolean) {
},
});
} else {
// all metrics
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue({
'all-metrics': {
type: 'all-metrics-type',
help: 'all-metrics-help',
},
a: {
type: 'counter',
help: 'a-metric-help',
},
a_bucket: {
type: 'histogram',
help: 'for functions',
},
new_histogram: {
type: 'histogram',
help: 'a native histogram',
},
// missing metadata for other metrics is tested for, see below
// Create the same metadata structure for retrieveMetricsMetadata
const mockMetadata: Record<string, { type: string; help: string }> = {};
listOfMetrics.forEach((metric) => {
if (metric === 'all-metrics') {
mockMetadata[metric] = { type: 'all-metrics-type', help: 'all-metrics-help' };
} else if (metric === 'a_bucket') {
mockMetadata[metric] = { type: 'histogram', help: 'for functions' };
} else if (metric === 'new_histogram') {
mockMetadata[metric] = { type: 'histogram', help: 'a native histogram' };
} else if (metric === 'a') {
mockMetadata[metric] = { type: 'counter', help: 'a-metric-help' };
} else {
mockMetadata[metric] = { type: 'counter', help: `${metric} metric help` };
}
});
languageProvider.retrieveMetricsMetadata = jest.fn().mockReturnValue(mockMetadata);
}
const datasource = new PrometheusDatasource(
@@ -1,107 +1,49 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/MetricsModal.tsx
import { cx } from '@emotion/css';
import debounce from 'debounce-promise';
import { useCallback, useEffect, useMemo, useReducer } from 'react';
import { SelectableValue } from '@grafana/data';
import { SelectableValue, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import {
Button,
ButtonGroup,
Icon,
Input,
Modal,
MultiSelect,
Pagination,
Spinner,
Toggletip,
useTheme2,
} from '@grafana/ui';
import { Icon, Input, Modal, MultiSelect, Pagination, Spinner, useStyles2 } from '@grafana/ui';
import { getDebounceTimeInMilliseconds } from '../../../caching';
import { METRIC_LABEL } from '../../../constants';
import { regexifyLabelValuesQueryString } from '../../parsingUtils';
import { formatPrometheusLabelFilters } from '../shared/formatter';
import { PrometheusDatasource } from '../../../datasource';
import { PromVisualQuery } from '../../types';
import { AdditionalSettings } from './AdditionalSettings';
import { FeedbackLink } from './FeedbackLink';
import { MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext';
import { ResultsTable } from './ResultsTable';
import { metricsModaltestIds } from './shared/testIds';
import { MetricsModalProps } from './shared/types';
import {
calculatePageList,
calculateResultsPerPage,
displayedMetrics,
getPlaceholders,
getPromTypes,
setMetrics,
tracking,
} from './state/helpers';
import {
buildMetrics,
DEFAULT_RESULTS_PER_PAGE,
filterMetricsBackend,
initialState,
MAXIMUM_RESULTS_PER_PAGE,
MetricsModalMetadata,
setDisableTextWrap,
setFullMetaSearch,
setFuzzySearchQuery,
setIncludeNullMetadata,
setIsLoading,
setMetaHaystack,
setNameHaystack,
setPageNum,
setResultsPerPage,
setSelectedTypes,
setUseBackend,
showAdditionalSettings,
stateSlice,
} from './state/state';
import { getStyles } from './styles';
import { getPlaceholders, getPromTypes } from './helpers';
import { getMetricsModalStyles } from './styles';
import { metricsModaltestIds } from './testIds';
import { PromFilterOption } from './types';
import { debouncedFuzzySearch } from './uFuzzy';
export const MetricsModal = (props: MetricsModalProps) => {
const { datasource, isOpen, onClose, onChange, query, initialMetrics, timeRange } = props;
interface MetricsModalProps {
datasource: PrometheusDatasource;
timeRange: TimeRange;
isOpen: boolean;
query: PromVisualQuery;
onClose: () => void;
onChange: (query: PromVisualQuery) => void;
}
const [state, dispatch] = useReducer(stateSlice.reducer, initialState(query));
const MetricsModalContent = (props: MetricsModalProps) => {
const { isOpen, onClose, onChange, query, timeRange } = props;
const theme = useTheme2();
const styles = getStyles(theme, state.disableTextWrap);
const {
isLoading,
filteredMetricsData,
debouncedBackendSearch,
pagination,
setPagination,
selectedTypes,
setSelectedTypes,
searchedText,
setSearchedText,
} = useMetricsModal();
const styles = useStyles2(getMetricsModalStyles);
const placeholders = getPlaceholders();
const promTypes = getPromTypes();
/**
* loads metrics and metadata on opening modal and switching off useBackend
*/
const updateMetricsMetadata = useCallback(async () => {
// *** Loading Gif
dispatch(setIsLoading(true));
// Because Combobox in MetricsCombobox doesn't use the same lifecycle as Select to open the Metrics Explorer
// it might not have loaded any metrics yet, so it instead passes in an async function to get the metrics
const metrics = typeof initialMetrics === 'function' ? await initialMetrics() : initialMetrics;
const data: MetricsModalMetadata = await setMetrics(datasource, query, metrics);
dispatch(
buildMetrics({
isLoading: false,
hasMetadata: data.hasMetadata,
metrics: data.metrics,
metaHaystackDictionary: data.metaHaystackDictionary,
nameHaystackDictionary: data.nameHaystackDictionary,
totalMetricCount: data.metrics.length,
filteredMetricCount: data.metrics.length,
})
);
}, [query, datasource, initialMetrics]);
useEffect(() => {
updateMetricsMetadata();
}, [updateMetricsMetadata]);
const typeOptions: SelectableValue[] = promTypes.map((t: PromFilterOption) => {
return {
value: t.value,
@@ -110,96 +52,10 @@ export const MetricsModal = (props: MetricsModalProps) => {
};
});
/**
* The backend debounced search
*/
const debouncedBackendSearch = useMemo(
() =>
debounce(async (metricText: string) => {
dispatch(setIsLoading(true));
const queryString = regexifyLabelValuesQueryString(metricText);
const filterArray = query.labels ? formatPrometheusLabelFilters(query.labels) : [];
const match = `{__name__=~".*${queryString}"${filterArray ? filterArray.join('') : ''}}`;
const results = await datasource.languageProvider.queryLabelValues(timeRange, METRIC_LABEL, match);
const resultsOptions = results.map((result) => ({
value: result,
}));
dispatch(
filterMetricsBackend({
metrics: resultsOptions,
filteredMetricCount: resultsOptions.length,
isLoading: false,
})
);
}, getDebounceTimeInMilliseconds(datasource.cacheLevel)),
[datasource.cacheLevel, datasource.languageProvider, query.labels, timeRange]
);
function fuzzyNameDispatch(haystackData: string[][]) {
dispatch(setNameHaystack(haystackData));
}
function fuzzyMetaDispatch(haystackData: string[][]) {
dispatch(setMetaHaystack(haystackData));
}
function searchCallback(query: string, fullMetaSearchVal: boolean) {
if (state.useBackend && query === '') {
// get all metrics data if a user erases everything in the input
updateMetricsMetadata();
} else if (state.useBackend) {
debouncedBackendSearch(query);
} else {
// search either the names or all metadata
// fuzzy search go!
if (fullMetaSearchVal) {
debouncedFuzzySearch(Object.keys(state.metaHaystackDictionary), query, fuzzyMetaDispatch);
} else {
debouncedFuzzySearch(Object.keys(state.nameHaystackDictionary), query, fuzzyNameDispatch);
}
}
}
/* Settings switches */
const additionalSettings = (
<AdditionalSettings
state={state}
onChangeFullMetaSearch={() => {
const newVal = !state.fullMetaSearch;
dispatch(setFullMetaSearch(newVal));
onChange({ ...query, fullMetaSearch: newVal });
searchCallback(state.fuzzySearchQuery, newVal);
}}
onChangeIncludeNullMetadata={() => {
dispatch(setIncludeNullMetadata(!state.includeNullMetadata));
onChange({ ...query, includeNullMetadata: !state.includeNullMetadata });
}}
onChangeDisableTextWrap={() => {
dispatch(setDisableTextWrap());
onChange({ ...query, disableTextWrap: !state.disableTextWrap });
tracking('grafana_prom_metric_encycopedia_disable_text_wrap_interaction', state, '');
}}
onChangeUseBackend={() => {
const newVal = !state.useBackend;
dispatch(setUseBackend(newVal));
onChange({ ...query, useBackend: newVal });
if (newVal === false) {
// rebuild the metrics metadata if we turn off useBackend
updateMetricsMetadata();
} else {
// check if there is text in the browse search and update
if (state.fuzzySearchQuery !== '') {
debouncedBackendSearch(state.fuzzySearchQuery);
}
// otherwise wait for user typing
}
}}
/>
);
const searchCallback = (query: string, fullMetaSearchVal?: boolean) => {
setSearchedText(query);
debouncedBackendSearch(timeRange, query);
};
return (
<Modal
@@ -220,58 +76,27 @@ export const MetricsModal = (props: MetricsModalProps) => {
autoFocus={true}
data-testid={metricsModaltestIds.searchMetric}
placeholder={placeholders.browse}
value={state.fuzzySearchQuery}
value={searchedText}
onInput={(e) => {
const value = e.currentTarget.value ?? '';
dispatch(setFuzzySearchQuery(value));
searchCallback(value, state.fullMetaSearch);
setSearchedText(value);
setPagination({ ...pagination, pageNum: 1 });
searchCallback(value);
}}
/>
</div>
{state.hasMetadata && (
<div className={styles.inputItem}>
<MultiSelect
data-testid={metricsModaltestIds.selectType}
inputId="my-select"
options={typeOptions}
value={state.selectedTypes}
placeholder={placeholders.type}
onChange={(v) => dispatch(setSelectedTypes(v))}
/>
</div>
)}
<div>
<Spinner className={`${styles.loadingSpinner} ${state.isLoading ? styles.visible : ''}`} />
</div>
<div className={styles.inputItem}>
<Toggletip
aria-label={t(
'grafana-prometheus.querybuilder.metrics-modal.aria-label-additional-settings',
'Additional settings'
)}
content={additionalSettings}
placement="bottom-end"
closeButton={false}
>
<ButtonGroup className={styles.settingsBtn}>
<Button
variant="secondary"
size="md"
onClick={() => dispatch(showAdditionalSettings())}
data-testid={metricsModaltestIds.showAdditionalSettings}
className={styles.noBorder}
>
<Trans i18nKey="grafana-prometheus.querybuilder.metrics-modal.additional-settings">
Additional Settings
</Trans>
</Button>
<Button
className={styles.noBorder}
variant="secondary"
icon={state.showAdditionalSettings ? 'angle-up' : 'angle-down'}
/>
</ButtonGroup>
</Toggletip>
<MultiSelect
data-testid={metricsModaltestIds.selectType}
inputId="my-select"
options={typeOptions}
value={selectedTypes}
placeholder={placeholders.filterType}
onChange={setSelectedTypes}
/>
</div>
<div>
<Spinner className={`${styles.loadingSpinner} ${isLoading ? styles.visible : ''}`} />
</div>
</div>
<div className={styles.resultsData}>
@@ -298,61 +123,23 @@ export const MetricsModal = (props: MetricsModalProps) => {
)}
</div>
<div className={styles.results}>
{state.metrics && (
<ResultsTable
metrics={displayedMetrics(state, dispatch)}
onChange={onChange}
onClose={onClose}
query={query}
state={state}
disableTextWrap={state.disableTextWrap}
/>
)}
{filteredMetricsData && <ResultsTable onChange={onChange} onClose={onClose} query={query} />}
</div>
<div className={styles.resultsFooter}>
<div className={styles.resultsAmount}>
<Trans
i18nKey="grafana-prometheus.querybuilder.metrics-modal.results-amount"
values={{ num: state.filteredMetricCount }}
count={state.totalMetricCount}
>
Showing {'{{num}}'} of {'{{count}}'} results
</Trans>
</div>
<Pagination
currentPage={state.pageNum ?? 1}
numberOfPages={calculatePageList(state).length}
onNavigate={(val: number) => {
const page = val ?? 1;
dispatch(setPageNum(page));
}}
currentPage={pagination.pageNum > pagination.totalPageNum ? 1 : pagination.pageNum}
numberOfPages={pagination.totalPageNum}
onNavigate={(val: number) => setPagination({ ...pagination, pageNum: val ?? 1 })}
/>
<div className={styles.resultsPerPageWrapper}>
<p className={styles.resultsPerPageLabel}>
<Trans i18nKey="grafana-prometheus.querybuilder.metrics-modal.results-per-page">Results per page</Trans>
</p>
<Input
data-testid={metricsModaltestIds.resultsPerPage}
value={calculateResultsPerPage(state.resultsPerPage, DEFAULT_RESULTS_PER_PAGE, MAXIMUM_RESULTS_PER_PAGE)}
placeholder={t(
'grafana-prometheus.querybuilder.metrics-modal.placeholder-results-per-page',
'results per page'
)}
width={10}
title={'The maximum results per page is ' + MAXIMUM_RESULTS_PER_PAGE}
type="number"
onInput={(e) => {
const value = +e.currentTarget.value;
if (isNaN(value) || value >= MAXIMUM_RESULTS_PER_PAGE) {
return;
}
dispatch(setResultsPerPage(value));
}}
/>
</div>
</div>
</Modal>
);
};
export const MetricsModal = (props: MetricsModalProps) => {
return (
<MetricsModalContextProvider languageProvider={props.datasource.languageProvider}>
<MetricsModalContent {...props} />
</MetricsModalContextProvider>
);
};
@@ -0,0 +1,329 @@
import { act, render, renderHook, waitFor } from '@testing-library/react';
import { ReactNode } from 'react';
import { TimeRange } from '@grafana/data';
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext';
import { generateMetricData } from './helpers';
// Mock dependencies
jest.mock('./helpers', () => ({
generateMetricData: jest.fn(),
}));
const mockGenerateMetricData = generateMetricData as jest.MockedFunction<typeof generateMetricData>;
// Mock language provider
const mockLanguageProvider: PrometheusLanguageProviderInterface = {
queryMetricsMetadata: jest.fn(),
queryLabelValues: jest.fn(),
retrieveMetricsMetadata: jest.fn(),
} as unknown as PrometheusLanguageProviderInterface;
// Helper to create wrapper component
const createWrapper = (languageProvider = mockLanguageProvider) => {
return ({ children }: { children: ReactNode }) => (
<MetricsModalContextProvider languageProvider={languageProvider}>{children}</MetricsModalContextProvider>
);
};
// Sample time range for tests
const defaultTimeRange: TimeRange = {
from: 'now-1h' as unknown as TimeRange['from'],
to: 'now' as unknown as TimeRange['to'],
raw: {
from: 'now-1h',
to: 'now',
},
};
describe('MetricsModalContext', () => {
let consoleSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
// Mock console.error to suppress React act() warnings
consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Default mock implementations
mockGenerateMetricData.mockImplementation((metric) => ({
value: metric,
type: 'counter',
description: 'Test metric',
}));
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
test_metric: { type: 'counter', help: 'Test metric' },
});
});
afterEach(() => {
consoleSpy.mockRestore();
});
describe('useMetricsModal hook', () => {
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useMetricsModal());
}).toThrow('useMetricsModal must be used within a MetricsModalContextProvider');
});
it('should provide context value when used within provider', () => {
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
expect(result.current).toBeDefined();
expect(result.current.isLoading).toBe(true); // Initially loading
expect(result.current.filteredMetricsData).toEqual([]);
expect(result.current.pagination).toEqual({
pageNum: 1,
totalPageNum: 1,
resultsPerPage: DEFAULT_RESULTS_PER_PAGE,
});
expect(result.current.selectedTypes).toEqual([]);
expect(result.current.searchedText).toBe('');
});
});
describe('State management', () => {
it('should update pagination', () => {
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
const expectedPagination = { pageNum: 1, resultsPerPage: 50, totalPageNum: 1 };
act(() => {
result.current.setPagination({ pageNum: 2, resultsPerPage: 50, totalPageNum: 3 });
});
expect(result.current.pagination).toEqual(expectedPagination);
});
it('should update selected types', () => {
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
const newTypes = [{ value: 'counter', label: 'Counter' }];
act(() => {
result.current.setSelectedTypes(newTypes);
});
expect(result.current.selectedTypes).toEqual(newTypes);
});
it('should update searched text', () => {
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
act(() => {
result.current.setSearchedText('test_metric');
});
expect(result.current.searchedText).toBe('test_metric');
});
it('should update loading state', () => {
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
act(() => {
result.current.setIsLoading(false);
});
expect(result.current.isLoading).toBe(false);
});
});
describe('Metadata fetching', () => {
it('should load initial metadata on mount', async () => {
const mockMetadata = {
cpu_usage: { type: 'gauge', help: 'CPU usage percentage' },
memory_usage: { type: 'gauge', help: 'Memory usage bytes' },
};
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue(mockMetadata);
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
// Wait for metadata to load
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockLanguageProvider.queryMetricsMetadata).toHaveBeenCalledWith(1000);
expect(mockGenerateMetricData).toHaveBeenCalledWith('cpu_usage', mockLanguageProvider);
expect(mockGenerateMetricData).toHaveBeenCalledWith('memory_usage', mockLanguageProvider);
expect(result.current.filteredMetricsData).toHaveLength(2);
});
it('should handle empty metadata response', async () => {
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({});
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.filteredMetricsData).toEqual([]);
});
it('should handle metadata fetch error', async () => {
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.filteredMetricsData).toEqual([]);
});
});
describe('Backend search', () => {
it('should perform backend search with results', async () => {
(mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['test_metric', 'other_metric']);
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.debouncedBackendSearch(defaultTimeRange, 'test');
});
expect(mockLanguageProvider.queryLabelValues).toHaveBeenCalledWith(
defaultTimeRange,
'__name__',
'{__name__=~"(?i).*test.*"}'
);
expect(result.current.filteredMetricsData).toHaveLength(1);
});
it('should handle backend search error', async () => {
(mockLanguageProvider.queryLabelValues as jest.Mock).mockRejectedValue(new Error('Search failed'));
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.debouncedBackendSearch(defaultTimeRange, 'test');
});
expect(result.current.filteredMetricsData).toEqual([]);
expect(result.current.isLoading).toBe(false);
});
});
describe('Filtering logic', () => {
it('should return all metrics when no types are selected', async () => {
mockGenerateMetricData.mockImplementation((metric) => ({
value: metric,
type: 'counter',
description: 'Test metric',
}));
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
test_metric: { type: 'counter', help: 'Test metric' },
});
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.filteredMetricsData).toHaveLength(1);
expect(result.current.selectedTypes).toEqual([]);
});
it('should filter metrics by selected type', async () => {
mockGenerateMetricData.mockImplementation((metric) => ({
value: metric,
type: metric === 'counter_metric' ? 'counter' : 'gauge',
description: 'Test metric',
}));
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
counter_metric: { type: 'counter', help: 'Counter metric' },
gauge_metric: { type: 'gauge', help: 'Gauge metric' },
});
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
act(() => {
result.current.setSelectedTypes([{ value: 'counter', label: 'Counter' }]);
});
expect(result.current.filteredMetricsData).toHaveLength(1);
expect(result.current.filteredMetricsData[0].value).toBe('counter_metric');
});
it('should handle metrics without type when "no type" is selected', async () => {
mockGenerateMetricData.mockImplementation((metric) => ({
value: metric,
type: metric === 'no_type_metric' ? undefined : 'counter',
description: 'Test metric',
}));
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
counter_metric: { type: 'counter', help: 'Counter metric' },
no_type_metric: { help: 'Metric without type' },
});
const { result } = renderHook(() => useMetricsModal(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
act(() => {
result.current.setSelectedTypes([{ value: 'no type', label: 'No Type' }]);
});
expect(result.current.filteredMetricsData).toHaveLength(1);
expect(result.current.filteredMetricsData[0].value).toBe('no_type_metric');
});
});
describe('Component integration', () => {
it('should render provider without errors', () => {
const TestComponent = () => {
return <div data-testid="test">frontend</div>;
};
const { getByTestId } = render(
<MetricsModalContextProvider languageProvider={mockLanguageProvider}>
<TestComponent />
</MetricsModalContextProvider>
);
expect(getByTestId('test')).toHaveTextContent('frontend');
});
});
});
@@ -0,0 +1,201 @@
import debounce from 'debounce-promise';
import {
createContext,
FC,
PropsWithChildren,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { SelectableValue, TimeRange } from '@grafana/data';
import { METRIC_LABEL, PROMETHEUS_QUERY_BUILDER_MAX_RESULTS } from '../../../constants';
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
import { regexifyLabelValuesQueryString } from '../../parsingUtils';
import { QueryBuilderLabelFilter } from '../../shared/types';
import { formatPrometheusLabelFilters } from '../formatter';
import { generateMetricData } from './helpers';
import { MetricData, MetricsData } from './types';
import { fuzzySearch } from './uFuzzy';
export const DEFAULT_RESULTS_PER_PAGE = 25;
type Pagination = {
pageNum: number;
resultsPerPage: number;
totalPageNum: number;
};
type MetricsModalContextValue = {
isLoading: boolean;
setIsLoading: (val: boolean) => void;
filteredMetricsData: MetricData[];
debouncedBackendSearch: (
timeRange: TimeRange,
metricText: string,
queryLabels?: QueryBuilderLabelFilter[]
) => Promise<void>;
pagination: Pagination;
setPagination: (val: Pagination) => void;
selectedTypes: Array<SelectableValue<string>>;
setSelectedTypes: (val: Array<SelectableValue<string>>) => void;
searchedText: string;
setSearchedText: (val: string) => void;
};
const MetricsModalContext = createContext<MetricsModalContextValue | undefined>(undefined);
type MetricsModalContextProviderProps = {
languageProvider: PrometheusLanguageProviderInterface;
};
export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalContextProviderProps>> = ({
children,
languageProvider,
}) => {
const [isLoading, setIsLoading] = useState(true);
const [metricsData, setMetricsData] = useState<MetricsData>([]);
const [pagination, setPagination] = useState<Pagination>({
pageNum: 1,
totalPageNum: 1,
resultsPerPage: DEFAULT_RESULTS_PER_PAGE,
});
const [selectedTypes, setSelectedTypes] = useState<Array<SelectableValue<string>>>([]);
const [searchedText, setSearchedText] = useState('');
const filteredMetricsData = useMemo(() => {
if (selectedTypes.length === 0) {
return metricsData;
}
// Filter metrics based on selected types
return metricsData.filter((metric: MetricData) => {
return selectedTypes.some((selectedType) => {
// Handle metrics with defined types
if (metric.type && selectedType.value) {
return metric.type.includes(selectedType.value);
}
// Handle metrics without type when "no type" is selected
if (!metric.type && selectedType.value === 'no type') {
return true;
}
return false;
});
});
}, [metricsData, selectedTypes]);
useEffect(() => {
const totalPageNum =
filteredMetricsData.length === 0 ? 1 : Math.ceil(filteredMetricsData.length / pagination.resultsPerPage);
const pageNum = pagination.pageNum > totalPageNum ? 1 : pagination.pageNum;
setPagination((prevPagination) => ({
...prevPagination,
totalPageNum,
pageNum,
}));
}, [filteredMetricsData.length, pagination.resultsPerPage, pagination.pageNum]);
// Track the latest search ID to handle race conditions
const latestSearchIdRef = useRef<number>(0);
const fetchMetadata = useCallback(async () => {
try {
setIsLoading(true);
const metadata = await languageProvider.queryMetricsMetadata(PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
if (Object.keys(metadata).length === 0) {
setMetricsData([]);
} else {
const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider));
setMetricsData(processedData);
}
} catch (error) {
setMetricsData([]);
} finally {
setIsLoading(false);
}
}, [languageProvider]);
const debouncedBackendSearch = useMemo(
() =>
debounce(async (timeRange: TimeRange, metricText: string, queryLabels?: QueryBuilderLabelFilter[]) => {
// Generate unique search ID to handle race conditions
const searchId = ++latestSearchIdRef.current;
try {
if (metricText === '') {
await fetchMetadata();
return;
}
setIsLoading(true);
const queryString = regexifyLabelValuesQueryString(metricText);
const filterArray = queryLabels ? formatPrometheusLabelFilters(queryLabels) : [];
const match = `{__name__=~"(?i).*${queryString}"${filterArray ? filterArray.join('') : ''}}`;
const results = await languageProvider.queryLabelValues(timeRange, METRIC_LABEL, match);
// Check if this is still the most recent search
if (searchId !== latestSearchIdRef.current) {
return; // Ignore outdated results
}
const [fuzzyOrderedMetrics] = fuzzySearch(results, queryString);
const resultsOptions: MetricsData = fuzzyOrderedMetrics.map((m) => generateMetricData(m, languageProvider));
setMetricsData(resultsOptions);
setIsLoading(false);
} catch (error) {
// Only update state if this is still the latest search
if (searchId === latestSearchIdRef.current) {
console.error('Backend search failed:', error);
setMetricsData([]); // Clear results on error
setIsLoading(false);
}
}
}, 300),
[fetchMetadata, languageProvider]
);
useEffect(() => {
fetchMetadata();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<MetricsModalContext.Provider
value={{
isLoading,
setIsLoading,
filteredMetricsData,
debouncedBackendSearch,
pagination,
setPagination,
selectedTypes,
setSelectedTypes,
searchedText,
setSearchedText,
}}
>
{children}
</MetricsModalContext.Provider>
);
};
export function useMetricsModal() {
const context = useContext(MetricsModalContext);
if (context === undefined) {
throw new Error('useMetricsModal must be used within a MetricsModalContextProvider');
}
return context;
}
@@ -1,65 +1,62 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/ResultsTable.tsx
import { css } from '@emotion/css';
import { ReactElement } from 'react';
import { ReactElement, useMemo } from 'react';
import Highlighter from 'react-highlight-words';
import { GrafanaTheme2 } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { Button, Icon, Tooltip, useTheme2 } from '@grafana/ui';
import { Icon, Tooltip, useStyles2 } from '@grafana/ui';
import { docsTip } from '../../../configuration/shared/utils';
import { PromVisualQuery } from '../../types';
import { tracking } from './state/helpers';
import { MetricsModalState } from './state/state';
import { MetricData, MetricsData } from './types';
import { useMetricsModal } from './MetricsModalContext';
import { getResultsTableStyles } from './styles';
import { MetricData } from './types';
type ResultsTableProps = {
metrics: MetricsData;
onChange: (query: PromVisualQuery) => void;
onClose: () => void;
query: PromVisualQuery;
state: MetricsModalState;
disableTextWrap: boolean;
};
export function ResultsTable(props: ResultsTableProps) {
const { metrics, onChange, onClose, query, state, disableTextWrap } = props;
const { onChange, onClose, query } = props;
const {
isLoading,
filteredMetricsData,
pagination: { pageNum, resultsPerPage },
selectedTypes,
searchedText,
} = useMetricsModal();
const theme = useTheme2();
const styles = getStyles(theme, disableTextWrap);
const slicedMetrics = useMemo(() => {
const startIndex = (pageNum - 1) * resultsPerPage;
const endIndex = startIndex + resultsPerPage;
return filteredMetricsData.slice(startIndex, endIndex);
}, [filteredMetricsData, pageNum, resultsPerPage]);
const styles = useStyles2(getResultsTableStyles);
function selectMetric(metric: MetricData) {
if (metric.value) {
onChange({ ...query, metric: metric.value });
tracking('grafana_prom_metric_encycopedia_tracking', state, metric.value);
onClose();
}
}
function metaRows(metric: MetricData) {
if (state.fullMetaSearch && metric) {
return (
<>
<td>{displayType(metric.type ?? '')}</td>
<td>
<Highlighter
textToHighlight={metric.description ?? ''}
searchWords={state.metaHaystackMatches}
autoEscape
highlightClassName={styles.matchHighLight}
/>
</td>
</>
);
} else {
return (
<>
<td>{displayType(metric.type ?? '')}</td>
<td>{metric.description ?? ''}</td>
</>
);
}
return (
<>
<td>{displayType(metric.type ?? '')}</td>
<td>
<Highlighter
textToHighlight={metric.description ?? ''}
searchWords={[]}
autoEscape
highlightClassName={styles.matchHighLight}
/>
</td>
</>
);
}
function addHelpIcon(fullType: string, descriptiveType: string, link: string) {
@@ -105,7 +102,7 @@ export function ResultsTable(props: ResultsTableProps) {
function noMetricsMessages(): ReactElement {
let message;
if (!state.fuzzySearchQuery) {
if (!searchedText) {
message = t(
'grafana-prometheus.querybuilder.results-table.message-no-metrics-found',
'There are no metrics found in the data source.'
@@ -119,7 +116,7 @@ export function ResultsTable(props: ResultsTableProps) {
);
}
if (state.fuzzySearchQuery || state.selectedTypes.length > 0) {
if (searchedText || selectedTypes.length > 0) {
message = t(
'grafana-prometheus.querybuilder.results-table.message-expand-search',
'There are no metrics found. Try to expand your search and filters.'
@@ -133,21 +130,6 @@ export function ResultsTable(props: ResultsTableProps) {
);
}
function textHighlight(state: MetricsModalState) {
if (state.useBackend) {
// highlight the input only for the backend search
// this highlight is equivalent to how the metric select highlights
// look into matching on regex input
return [state.fuzzySearchQuery];
} else if (state.fullMetaSearch) {
// highlight the matches in the ufuzzy metaHaystack
return state.metaHaystackMatches;
} else {
// highlight the ufuzzy name matches
return state.nameHaystackMatches;
}
}
return (
<table className={styles.table}>
<thead className={styles.stickyHeader}>
@@ -155,115 +137,35 @@ export function ResultsTable(props: ResultsTableProps) {
<th className={`${styles.nameWidth} ${styles.tableHeaderPadding}`}>
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.name">Name</Trans>
</th>
{state.hasMetadata && (
<>
<th className={`${styles.typeWidth} ${styles.tableHeaderPadding}`}>
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.type">Type</Trans>
</th>
<th className={`${styles.descriptionWidth} ${styles.tableHeaderPadding}`}>
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.description">Description</Trans>
</th>
</>
)}
<th className={styles.selectButtonWidth}> </th>
<th className={`${styles.typeWidth} ${styles.tableHeaderPadding}`}>
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.type">Type</Trans>
</th>
<th className={`${styles.descriptionWidth} ${styles.tableHeaderPadding}`}>
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.description">Description</Trans>
</th>
</tr>
</thead>
<tbody>
<>
{metrics.length > 0 &&
metrics.map((metric: MetricData, idx: number) => {
{slicedMetrics.length > 0 &&
slicedMetrics.map((metric: MetricData, idx: number) => {
return (
<tr key={metric?.value ?? idx} className={styles.row}>
<tr key={metric?.value ?? idx} className={styles.row} onClick={() => selectMetric(metric)}>
<td className={styles.nameOverflow}>
<Highlighter
textToHighlight={metric?.value ?? ''}
searchWords={textHighlight(state)}
searchWords={searchedText.split(' ')}
autoEscape
highlightClassName={styles.matchHighLight}
/>
</td>
{state.hasMetadata && metaRows(metric)}
<td>
<Button
size="md"
variant="secondary"
onClick={() => selectMetric(metric)}
className={styles.centerButton}
>
<Trans i18nKey="grafana-prometheus.querybuilder.results-table.select">Select</Trans>
</Button>
</td>
{metaRows(metric)}
</tr>
);
})}
{metrics.length === 0 && !state.isLoading && noMetricsMessages()}
{slicedMetrics.length === 0 && !isLoading && noMetricsMessages()}
</>
</tbody>
</table>
);
}
const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
return {
table: css({
tableLayout: disableTextWrap ? undefined : 'fixed',
borderRadius: theme.shape.radius.default,
width: '100%',
whiteSpace: disableTextWrap ? 'nowrap' : 'normal',
td: {
padding: theme.spacing(1),
},
'td,th': {
minWidth: theme.spacing(3),
borderBottom: `1px solid ${theme.colors.border.weak}`,
},
}),
row: css({
label: 'row',
borderBottom: `1px solid ${theme.colors.border.weak}`,
'&:last-child': {
borderBottom: 0,
},
}),
tableHeaderPadding: css({
padding: '8px',
}),
matchHighLight: css({
background: 'inherit',
color: theme.components.textHighlight.text,
backgroundColor: theme.components.textHighlight.background,
}),
nameWidth: css({
width: disableTextWrap ? undefined : '37.5%',
}),
nameOverflow: css({
overflowWrap: disableTextWrap ? undefined : 'anywhere',
}),
typeWidth: css({
width: disableTextWrap ? undefined : '15%',
}),
descriptionWidth: css({
width: disableTextWrap ? undefined : '35%',
}),
selectButtonWidth: css({
width: disableTextWrap ? undefined : '12.5%',
}),
stickyHeader: css({
position: 'sticky',
top: 0,
backgroundColor: theme.colors.background.primary,
}),
noResults: css({
textAlign: 'center',
color: theme.colors.text.secondary,
}),
tooltipSpace: css({
marginLeft: '4px',
}),
centerButton: css({
display: 'block',
margin: 'auto',
border: 'none',
}),
};
};
@@ -0,0 +1,313 @@
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
import { PromMetricsMetadata } from '../../../types';
import { calculatePageList, generateMetricData, getPlaceholders, getPromTypes } from './helpers';
import { MetricsData } from './types';
// Mock the language provider
const createMockLanguageProvider = (metadata: PromMetricsMetadata = {}): PrometheusLanguageProviderInterface =>
({
retrieveMetricsMetadata: jest.fn().mockReturnValue(metadata),
}) as unknown as PrometheusLanguageProviderInterface;
describe('helpers.ts', () => {
describe('generateMetricData', () => {
it('should generate basic metric data', () => {
const mockProvider = createMockLanguageProvider({
test_metric: {
type: 'counter',
help: 'Test counter metric',
},
});
const result = generateMetricData('test_metric', mockProvider);
expect(result).toEqual({
value: 'test_metric',
type: 'counter',
description: 'Test counter metric',
});
});
it('should handle metric with no metadata', () => {
const mockProvider = createMockLanguageProvider({});
const result = generateMetricData('unknown_metric', mockProvider);
expect(result).toEqual({
value: 'unknown_metric',
type: undefined,
description: undefined,
});
});
it('should enhance type based on description for histogram', () => {
const mockProvider = createMockLanguageProvider({
test_metric: {
type: 'gauge',
help: 'This is a histogram metric for testing',
},
});
const result = generateMetricData('test_metric', mockProvider);
expect(result.type).toBe('gauge (histogram)');
});
it('should enhance type based on description for summary', () => {
const mockProvider = createMockLanguageProvider({
test_metric: {
type: 'counter',
help: 'This is a summary metric for testing',
},
});
const result = generateMetricData('test_metric', mockProvider);
expect(result.type).toBe('counter (summary)');
});
it('should not enhance type if already matches description', () => {
const mockProvider = createMockLanguageProvider({
test_metric: {
type: 'histogram',
help: 'This is a histogram metric for testing',
},
});
const result = generateMetricData('test_metric', mockProvider);
expect(result.type).toBe('native histogram'); // Should be native histogram, not enhanced
});
it('should detect native histogram vs classic histogram', () => {
const mockProvider = createMockLanguageProvider({
native_histogram: {
type: 'histogram',
help: 'Native histogram',
},
classic_bucket: {
type: 'histogram',
help: 'Classic histogram',
},
classic_bucket_with_labels: {
type: 'histogram',
help: 'Classic histogram with labels',
},
});
// Native histogram (no _bucket suffix)
const nativeResult = generateMetricData('native_histogram', mockProvider);
expect(nativeResult.type).toBe('native histogram');
// Classic histogram (with _bucket suffix)
const classicResult = generateMetricData('classic_bucket', mockProvider);
expect(classicResult.type).toBe('histogram');
// Classic histogram with labels
const classicWithLabelsResult = generateMetricData('classic_bucket_with_labels', mockProvider);
expect(classicWithLabelsResult.type).toBe('native histogram'); // No _bucket pattern match
});
it('should handle old histogram pattern matching', () => {
const mockProvider = createMockLanguageProvider({
test_bucket: { type: 'histogram', help: 'Test bucket metric' },
'test_bucket{le="0.1"}': { type: 'histogram', help: 'Test bucket with labels' },
test_histogram: { type: 'histogram', help: 'Test histogram metric' },
});
// Should be classic histogram (matches pattern)
expect(generateMetricData('test_bucket', mockProvider).type).toBe('histogram');
expect(generateMetricData('test_bucket{le="0.1"}', mockProvider).type).toBe('histogram');
// Should be native histogram (doesn't match pattern)
expect(generateMetricData('test_histogram', mockProvider).type).toBe('native histogram');
});
it('should handle case-insensitive description matching', () => {
const mockProvider = createMockLanguageProvider({
test_metric: {
type: 'gauge',
help: 'This is a HISTOGRAM metric for testing',
},
});
const result = generateMetricData('test_metric', mockProvider);
expect(result.type).toBe('gauge (histogram)');
});
it('should handle empty type with description enhancement', () => {
const mockProvider = createMockLanguageProvider({
test_metric: {
type: '',
help: 'This is a histogram metric for testing',
},
});
const result = generateMetricData('test_metric', mockProvider);
expect(result.type).toBe('native histogram');
});
it('should handle empty description', () => {
const mockProvider = createMockLanguageProvider({
test_metric: {
type: 'gauge',
help: '',
},
});
const result = generateMetricData('test_metric', mockProvider);
expect(result.type).toBe('gauge');
});
});
describe('calculatePageList', () => {
const createMetricsData = (length: number): MetricsData =>
Array.from({ length }, (_, i) => ({ value: `metric_${i}` }));
it('should return empty array for empty metrics data', () => {
expect(calculatePageList([], 10)).toEqual([]);
});
it('should return [1] for zero or negative results per page', () => {
const metricsData = createMetricsData(5);
expect(calculatePageList(metricsData, 0)).toEqual([1]);
expect(calculatePageList(metricsData, -5)).toEqual([1]);
});
it('should calculate correct page list for exact division', () => {
const metricsData = createMetricsData(20);
const result = calculatePageList(metricsData, 10);
expect(result).toEqual([1, 2]);
});
it('should calculate correct page list for non-exact division', () => {
const metricsData = createMetricsData(23);
const result = calculatePageList(metricsData, 10);
expect(result).toEqual([1, 2, 3]);
});
it('should handle single page scenario', () => {
const metricsData = createMetricsData(5);
const result = calculatePageList(metricsData, 10);
expect(result).toEqual([1]);
});
it('should handle very large datasets', () => {
const metricsData = createMetricsData(1000);
const result = calculatePageList(metricsData, 25);
expect(result).toHaveLength(40);
expect(result[0]).toBe(1);
expect(result[result.length - 1]).toBe(40);
});
it('should handle single item per page', () => {
const metricsData = createMetricsData(3);
const result = calculatePageList(metricsData, 1);
expect(result).toEqual([1, 2, 3]);
});
it('should handle fractional results per page', () => {
const metricsData = createMetricsData(10);
expect(calculatePageList(metricsData, 3.5)).toEqual([1, 2, 3]);
});
});
describe('getPromTypes', () => {
it('should return array of Prometheus types', () => {
const types = getPromTypes();
expect(Array.isArray(types)).toBe(true);
expect(types.length).toBe(7);
const expectedValues = ['counter', 'gauge', 'histogram', 'native histogram', 'summary', 'unknown', 'no type'];
const actualValues = types.map((type) => type.value);
expect(actualValues).toEqual(expectedValues);
});
it('should have correct structure for each type', () => {
const types = getPromTypes();
types.forEach((type) => {
expect(type).toHaveProperty('value');
expect(type).toHaveProperty('label');
expect(type).toHaveProperty('description');
expect(typeof type.value).toBe('string');
expect(typeof type.label).toBe('string');
expect(typeof type.description).toBe('string');
expect(type.value.length).toBeGreaterThan(0);
expect(type.label.length).toBeGreaterThan(0);
expect(type.description.length).toBeGreaterThan(0);
});
});
it('should return consistent results on multiple calls', () => {
const types1 = getPromTypes();
const types2 = getPromTypes();
expect(types1).toEqual(types2);
});
it('should have unique values', () => {
const types = getPromTypes();
const values = types.map((type) => type.value);
const uniqueValues = [...new Set(values)];
expect(values.length).toBe(uniqueValues.length);
});
it('should have descriptive labels', () => {
const types = getPromTypes();
types.forEach((type) => {
expect(type.label).not.toBe(type.value);
expect(type.label.length).toBeGreaterThanOrEqual(type.value.length);
});
});
});
describe('getPlaceholders', () => {
it('should return object with all required placeholders', () => {
const placeholders = getPlaceholders();
const expectedKeys = ['browse', 'filterType'];
expect(Object.keys(placeholders)).toEqual(expectedKeys);
});
it('should have string values for all placeholders', () => {
const placeholders = getPlaceholders();
Object.values(placeholders).forEach((placeholder) => {
expect(typeof placeholder).toBe('string');
expect(placeholder.length).toBeGreaterThan(0);
});
});
it('should return consistent results on multiple calls', () => {
const placeholders1 = getPlaceholders();
const placeholders2 = getPlaceholders();
expect(placeholders1).toEqual(placeholders2);
});
it('should contain expected placeholder content', () => {
const placeholders = getPlaceholders();
expect(placeholders.browse).toMatch(/search/i);
expect(placeholders.filterType).toMatch(/type/i);
});
it('should have descriptive placeholder text', () => {
const placeholders = getPlaceholders();
Object.values(placeholders).forEach((placeholder) => {
expect(placeholder.length).toBeGreaterThan(5);
expect(placeholder).toMatch(/[a-zA-Z]/);
});
});
});
});
@@ -0,0 +1,123 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts
import { t } from '@grafana/i18n';
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
import { MetricData, MetricsData, PromFilterOption } from './types';
// Constants
const HISTOGRAM_TYPES = ['histogram', 'summary'] as const;
const OLD_HISTOGRAM_PATTERN = /^\w+_bucket$|^\w+_bucket{.*}$/;
const HISTOGRAM_TYPE = 'histogram';
const NATIVE_HISTOGRAM_TYPE = 'native histogram';
/**
* Builds the metric data object with type and description
* @param metric - The metric name
* @param languageProvider - The Prometheus language provider interface
* @returns MetricData object with value, type, and description
*/
export const generateMetricData = (
metric: string,
languageProvider: PrometheusLanguageProviderInterface
): MetricData => {
const metadata = languageProvider.retrieveMetricsMetadata();
let type = metadata[metric]?.type;
const description = metadata[metric]?.help;
HISTOGRAM_TYPES.forEach((t) => {
if (description?.toLowerCase().includes(t) && type !== t) {
type = type ? `${type} (${t})` : t;
}
});
const oldHistogramMatch = metric.match(OLD_HISTOGRAM_PATTERN);
if (type === HISTOGRAM_TYPE && !oldHistogramMatch) {
type = NATIVE_HISTOGRAM_TYPE;
}
return {
value: metric,
type: type,
description: description,
};
};
export function calculatePageList(metricsData: MetricsData, resultsPerPage: number): number[] {
if (!Array.isArray(metricsData) || metricsData.length === 0) {
return [];
}
if (resultsPerPage <= 0) {
return [1];
}
const totalPages = Math.ceil(metricsData.length / resultsPerPage);
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
export const getPromTypes: () => PromFilterOption[] = () => [
{
value: 'counter',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-counter', 'Counter'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-counter',
'A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart.'
),
},
{
value: 'gauge',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-gauge', 'Gauge'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-gauge',
'A metric that represents a single numerical value that can arbitrarily go up and down.'
),
},
{
value: 'histogram',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-histogram', 'Histogram'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-histogram',
'A histogram samples observations (usually things like request durations or response sizes) and counts them in configurable buckets.'
),
},
{
value: 'native histogram',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-native-histogram', 'Native histogram'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-native-histogram',
'Native histograms are different from classic Prometheus histograms in a number of ways: Native histogram bucket boundaries are calculated by a formula that depends on the scale (resolution) of the native histogram, and are not user defined.'
),
},
{
value: 'summary',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-summary', 'Summary'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-summary',
'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.'
),
},
{
value: 'unknown',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-unknown', 'Unknown'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-unknown',
'These metrics have been given the type unknown in the metadata.'
),
},
{
value: 'no type',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-no-type', 'No type'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-no-type',
'These metrics have no defined type in the metadata.'
),
},
];
export const getPlaceholders = () => ({
browse: t('grafana-prometheus.querybuilder.get-placeholders.browse', 'Search metrics by name'),
filterType: t('grafana-prometheus.querybuilder.get-placeholders.type', 'Filter by type'),
});
@@ -1,30 +0,0 @@
import { TimeRange } from '@grafana/data';
import { PrometheusDatasource } from '../../../../datasource';
import { PromVisualQuery } from '../../../types';
interface MetricsModalState {
useBackend: boolean;
disableTextWrap: boolean;
includeNullMetadata: boolean;
fullMetaSearch: boolean;
hasMetadata: boolean;
}
export interface MetricsModalProps {
datasource: PrometheusDatasource;
isOpen: boolean;
query: PromVisualQuery;
onClose: () => void;
onChange: (query: PromVisualQuery) => void;
initialMetrics: string[] | (() => Promise<string[]>);
timeRange: TimeRange;
}
export interface AdditionalSettingsProps {
state: MetricsModalState;
onChangeFullMetaSearch: () => void;
onChangeIncludeNullMetadata: () => void;
onChangeDisableTextWrap: () => void;
onChangeUseBackend: () => void;
}
@@ -1,278 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/helpers.ts
import { AnyAction } from '@reduxjs/toolkit';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { PrometheusDatasource } from '../../../../datasource';
import { PromMetricsMetadata } from '../../../../types';
import { PromVisualQuery } from '../../../types';
import { HaystackDictionary, MetricData, MetricsData, PromFilterOption } from '../types';
import { MetricsModalMetadata, MetricsModalState, setFilteredMetricCount } from './state';
export async function setMetrics(
datasource: PrometheusDatasource,
query: PromVisualQuery,
initialMetrics?: string[]
): Promise<MetricsModalMetadata> {
// metadata is set in the metric select now
// use this to disable metadata search and display
let hasMetadata = true;
const metadata = datasource.languageProvider.retrieveMetricsMetadata();
if (metadata && Object.keys(metadata).length === 0) {
hasMetadata = false;
}
let nameHaystackDictionaryData: HaystackDictionary = {};
let metaHaystackDictionaryData: HaystackDictionary = {};
// pass in metrics from getMetrics in the query builder, reduced in the metric select
let metricsData: MetricsData | undefined;
metricsData = initialMetrics?.map((m: string) => {
const metricData = buildMetricData(m, datasource);
const metaDataString = `${m}¦${metricData.description}`;
nameHaystackDictionaryData[m] = metricData;
metaHaystackDictionaryData[metaDataString] = metricData;
return metricData;
});
return {
isLoading: false,
hasMetadata: hasMetadata,
metrics: metricsData ?? [],
metaHaystackDictionary: metaHaystackDictionaryData,
nameHaystackDictionary: nameHaystackDictionaryData,
totalMetricCount: metricsData?.length ?? 0,
filteredMetricCount: metricsData?.length ?? 0,
};
}
/**
* Builds the metric data object with type and description
*
* @param metric The metric name
* @param datasource The Prometheus datasource for mapping metradata to the metric name
* @returns A MetricData object.
*/
function buildMetricData(metric: string, datasource: PrometheusDatasource): MetricData {
let type = getMetadataType(metric, datasource.languageProvider.retrieveMetricsMetadata());
const description = getMetadataHelp(metric, datasource.languageProvider.retrieveMetricsMetadata());
['histogram', 'summary'].forEach((t) => {
if (description?.toLowerCase().includes(t) && type !== t) {
type += ` (${t})`;
}
});
const oldHistogramMatch = metric.match(/^\w+_bucket$|^\w+_bucket{.*}$/);
if (type === 'histogram' && !oldHistogramMatch) {
type = 'native histogram';
}
const metricData: MetricData = {
value: metric,
type: type,
description: description,
};
return metricData;
}
function getMetadataHelp(metric: string, metadata: PromMetricsMetadata): string | undefined {
return metadata[metric]?.help;
}
function getMetadataType(metric: string, metadata: PromMetricsMetadata): string | undefined {
return metadata[metric]?.type;
}
/**
* The filtered and paginated metrics displayed in the modal
* */
export function displayedMetrics(state: MetricsModalState, dispatch: React.Dispatch<AnyAction>) {
const filteredSorted: MetricsData = filterMetrics(state);
if (!state.isLoading && state.filteredMetricCount !== filteredSorted.length) {
dispatch(setFilteredMetricCount(filteredSorted.length));
}
return sliceMetrics(filteredSorted, state.pageNum, state.resultsPerPage);
}
/**
* Filter the metrics with all the options, fuzzy, type, null metadata
*/
function filterMetrics(state: MetricsModalState): MetricsData {
let filteredMetrics: MetricsData = state.metrics;
if (state.fuzzySearchQuery && !state.useBackend) {
if (state.fullMetaSearch) {
filteredMetrics = state.metaHaystackOrder.map((needle: string) => state.metaHaystackDictionary[needle]);
} else {
filteredMetrics = state.nameHaystackOrder.map((needle: string) => state.nameHaystackDictionary[needle]);
}
}
if (state.selectedTypes.length > 0) {
filteredMetrics = filteredMetrics.filter((m: MetricData, idx) => {
// Matches type
const matchesSelectedType = state.selectedTypes.some((t) => {
if (m.type && t.value) {
return m.type.includes(t.value);
}
if (!m.type && t.value === 'no type') {
return true;
}
return false;
});
// when a user filters for type, only return metrics with defined types
return matchesSelectedType;
});
}
if (!state.includeNullMetadata) {
filteredMetrics = filteredMetrics.filter((m: MetricData) => {
return m.type !== undefined && m.description !== undefined;
});
}
return filteredMetrics;
}
export function calculatePageList(state: MetricsModalState) {
if (!state.metrics.length) {
return [];
}
const calcResultsPerPage: number = state.resultsPerPage === 0 ? 1 : state.resultsPerPage;
const pages = Math.floor(filterMetrics(state).length / calcResultsPerPage) + 1;
return [...Array(pages).keys()].map((i) => i + 1);
}
function sliceMetrics(metrics: MetricsData, pageNum: number, resultsPerPage: number) {
const calcResultsPerPage: number = resultsPerPage === 0 ? 1 : resultsPerPage;
const start: number = pageNum === 1 ? 0 : (pageNum - 1) * calcResultsPerPage;
const end: number = start + calcResultsPerPage;
return metrics.slice(start, end);
}
export const calculateResultsPerPage = (results: number, defaultResults: number, max: number) => {
if (results < 1) {
return 1;
}
if (results > max) {
return max;
}
return results ?? defaultResults;
};
export function tracking(event: string, state?: MetricsModalState | null, metric?: string, query?: PromVisualQuery) {
switch (event) {
case 'grafana_prom_metric_encycopedia_tracking':
reportInteraction(event, {
metric: metric,
hasMetadata: state?.hasMetadata,
totalMetricCount: state?.totalMetricCount,
fuzzySearchQuery: state?.fuzzySearchQuery,
fullMetaSearch: state?.fullMetaSearch,
selectedTypes: state?.selectedTypes,
useRegexSearch: state?.useBackend,
includeResultsWithoutMetadata: state?.includeNullMetadata,
});
case 'grafana_prom_metric_encycopedia_disable_text_wrap_interaction':
reportInteraction(event, {
disableTextWrap: state?.disableTextWrap,
});
case 'grafana_prometheus_metric_encyclopedia_open':
reportInteraction(event, {
query: query,
});
}
}
export const getPromTypes: () => PromFilterOption[] = () => [
{
value: 'counter',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-counter', 'Counter'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-counter',
'A cumulative metric that represents a single monotonically increasing counter whose value can only increase or be reset to zero on restart.'
),
},
{
value: 'gauge',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-gauge', 'Gauge'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-gauge',
'A metric that represents a single numerical value that can arbitrarily go up and down.'
),
},
{
value: 'histogram',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-histogram', 'Histogram'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-histogram',
'A histogram samples observations (usually things like request durations or response sizes) and counts them in configurable buckets.'
),
},
{
value: 'native histogram',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-native-histogram', 'Native histogram'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-native-histogram',
'Native histograms are different from classic Prometheus histograms in a number of ways: Native histogram bucket boundaries are calculated by a formula that depends on the scale (resolution) of the native histogram, and are not user defined.'
),
},
{
value: 'summary',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-summary', 'Summary'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-summary',
'A summary samples observations (usually things like request durations and response sizes) and can calculate configurable quantiles over a sliding time window.'
),
},
{
value: 'unknown',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-unknown', 'Unknown'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-unknown',
'These metrics have been given the type unknown in the metadata.'
),
},
{
value: 'no type',
label: t('grafana-prometheus.querybuilder.get-prom-types.label-no-type', 'No type'),
description: t(
'grafana-prometheus.querybuilder.get-prom-types.description-no-type',
'These metrics have no defined type in the metadata.'
),
},
];
export const getPlaceholders = () => ({
browse: t('grafana-prometheus.querybuilder.get-placeholders.browse', 'Search metrics by name'),
metadataSearchSwitch: t(
'grafana-prometheus.querybuilder.get-placeholders.metadata-search-switch',
'Include description in search'
),
type: t('grafana-prometheus.querybuilder.get-placeholders.type', 'Filter by type'),
includeNullMetadata: t(
'grafana-prometheus.querybuilder.get-placeholders.include-null-metadata',
'Include results with no metadata'
),
setUseBackend: t('grafana-prometheus.querybuilder.get-placeholders.set-use-backend', 'Enable regex search'),
});
@@ -1,212 +0,0 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/state/state.ts
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { SelectableValue } from '@grafana/data';
import { PromVisualQuery } from '../../../types';
import { HaystackDictionary, MetricsData } from '../types';
export const DEFAULT_RESULTS_PER_PAGE = 100;
export const MAXIMUM_RESULTS_PER_PAGE = 1000;
/**
* Initial state for the metrics explorer
* @returns
*/
export function initialState(query?: PromVisualQuery): MetricsModalState {
return {
isLoading: true,
metrics: [],
hasMetadata: true,
metaHaystackDictionary: {},
metaHaystackMatches: [],
metaHaystackOrder: [],
nameHaystackDictionary: {},
nameHaystackOrder: [],
nameHaystackMatches: [],
totalMetricCount: 0,
filteredMetricCount: null,
resultsPerPage: DEFAULT_RESULTS_PER_PAGE,
pageNum: 1,
fuzzySearchQuery: '',
fullMetaSearch: query?.fullMetaSearch ?? false,
includeNullMetadata: query?.includeNullMetadata ?? true,
selectedTypes: [],
useBackend: query?.useBackend ?? false,
disableTextWrap: query?.disableTextWrap ?? false,
showAdditionalSettings: false,
};
}
/**
* The metrics explorer state object
*/
export interface MetricsModalState {
/** Used for the loading spinner */
isLoading: boolean;
/**
* Initial collection of metrics.
* The frontend filters do not impact this, but
* it is reduced by the backend search.
*/
metrics: MetricsData;
/** Field for disabling type select and switches that rely on metadata */
hasMetadata: boolean;
/** Used to display metrics and help with fuzzy order */
nameHaystackDictionary: HaystackDictionary;
/** Used to sort name fuzzy search by relevance */
nameHaystackOrder: string[];
/** Used to highlight text in fuzzy matches */
nameHaystackMatches: string[];
/** Used to display metrics and help with fuzzy order for search across all metadata */
metaHaystackDictionary: HaystackDictionary;
/** Used to sort meta fuzzy search by relevance */
metaHaystackOrder: string[];
/** Used to highlight text in fuzzy matches */
metaHaystackMatches: string[];
/** Total results computed on initialization */
totalMetricCount: number;
/** Set after filtering metrics */
filteredMetricCount: number | null;
/** Pagination field for showing results in table */
resultsPerPage: number;
/** Pagination field */
pageNum: number;
/** The text query used to match metrics */
fuzzySearchQuery: string;
/** Enables the fuzzy meatadata search */
fullMetaSearch: boolean;
/** Includes results that are missing type and description */
includeNullMetadata: boolean;
/** Filter by prometheus type */
selectedTypes: Array<SelectableValue<string>>;
/** Filter by the series match endpoint instead of the fuzzy search */
useBackend: boolean;
/** Disable text wrap for descriptions in the results table */
disableTextWrap: boolean;
/** Display toggle switches for settings */
showAdditionalSettings: boolean;
}
/**
* Type for the useEffect get metadata function
*/
export type MetricsModalMetadata = {
isLoading: boolean;
metrics: MetricsData;
hasMetadata: boolean;
metaHaystackDictionary: HaystackDictionary;
nameHaystackDictionary: HaystackDictionary;
totalMetricCount: number;
filteredMetricCount: number | null;
};
// for updating the settings in the PromQuery model
export function getSettings(visQuery: PromVisualQuery): MetricsModalSettings {
return {
useBackend: visQuery?.useBackend ?? false,
disableTextWrap: visQuery?.disableTextWrap ?? false,
fullMetaSearch: visQuery?.fullMetaSearch ?? false,
includeNullMetadata: visQuery.includeNullMetadata ?? false,
};
}
export type MetricsModalSettings = {
useBackend?: boolean;
disableTextWrap?: boolean;
fullMetaSearch?: boolean;
includeNullMetadata?: boolean;
};
export const stateSlice = createSlice({
name: 'metrics-modal-state',
initialState: initialState(),
reducers: {
filterMetricsBackend: (
state,
action: PayloadAction<{
metrics: MetricsData;
filteredMetricCount: number;
isLoading: boolean;
}>
) => {
state.metrics = action.payload.metrics;
state.filteredMetricCount = action.payload.filteredMetricCount;
state.isLoading = action.payload.isLoading;
},
buildMetrics: (state, action: PayloadAction<MetricsModalMetadata>) => {
state.isLoading = action.payload.isLoading;
state.metrics = action.payload.metrics;
state.hasMetadata = action.payload.hasMetadata;
state.metaHaystackDictionary = action.payload.metaHaystackDictionary;
state.nameHaystackDictionary = action.payload.nameHaystackDictionary;
state.totalMetricCount = action.payload.totalMetricCount;
state.filteredMetricCount = action.payload.filteredMetricCount;
},
setIsLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setFilteredMetricCount: (state, action: PayloadAction<number>) => {
state.filteredMetricCount = action.payload;
},
setResultsPerPage: (state, action: PayloadAction<number>) => {
state.resultsPerPage = action.payload;
},
setPageNum: (state, action: PayloadAction<number>) => {
state.pageNum = action.payload;
},
setFuzzySearchQuery: (state, action: PayloadAction<string>) => {
state.fuzzySearchQuery = action.payload;
state.pageNum = 1;
},
setNameHaystack: (state, action: PayloadAction<string[][]>) => {
state.nameHaystackOrder = action.payload[0];
state.nameHaystackMatches = action.payload[1];
},
setMetaHaystack: (state, action: PayloadAction<string[][]>) => {
state.metaHaystackOrder = action.payload[0];
state.metaHaystackMatches = action.payload[1];
},
setFullMetaSearch: (state, action: PayloadAction<boolean>) => {
state.fullMetaSearch = action.payload;
state.pageNum = 1;
},
setIncludeNullMetadata: (state, action: PayloadAction<boolean>) => {
state.includeNullMetadata = action.payload;
state.pageNum = 1;
},
setSelectedTypes: (state, action: PayloadAction<Array<SelectableValue<string>>>) => {
state.selectedTypes = action.payload;
state.pageNum = 1;
},
setUseBackend: (state, action: PayloadAction<boolean>) => {
state.useBackend = action.payload;
state.fullMetaSearch = false;
state.pageNum = 1;
},
setDisableTextWrap: (state) => {
state.disableTextWrap = !state.disableTextWrap;
},
showAdditionalSettings: (state) => {
state.showAdditionalSettings = !state.showAdditionalSettings;
},
},
});
export const {
setIsLoading,
buildMetrics,
filterMetricsBackend,
setResultsPerPage,
setPageNum,
setFuzzySearchQuery,
setNameHaystack,
setMetaHaystack,
setFullMetaSearch,
setIncludeNullMetadata,
setSelectedTypes,
setUseBackend,
setDisableTextWrap,
showAdditionalSettings,
setFilteredMetricCount,
} = stateSlice.actions;
@@ -3,7 +3,7 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
export const getMetricsModalStyles = (theme: GrafanaTheme2) => {
return {
modal: css({
width: '85vw',
@@ -34,20 +34,9 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
minWidth: '100%',
},
}),
selectWrapper: css({
marginBottom: theme.spacing(1),
}),
resultsAmount: css({
color: theme.colors.text.secondary,
fontSize: '0.85rem',
padding: '0 0 4px 0',
}),
resultsData: css({
margin: `4px 0 ${theme.spacing(2)} 0`,
}),
resultsDataCount: css({
margin: 0,
}),
resultsDataFiltered: css({
color: theme.colors.text.secondary,
textAlign: 'center',
@@ -67,9 +56,9 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
position: 'sticky',
justifyContent: 'center',
}),
currentlySelected: css({
color: 'grey',
@@ -82,21 +71,69 @@ export const getStyles = (theme: GrafanaTheme2, disableTextWrap: boolean) => {
visible: css({
visibility: 'visible',
}),
settingsBtn: css({
float: 'right',
}),
noBorder: css({
border: 'none',
}),
resultsPerPageLabel: css({
color: theme.colors.text.secondary,
opacity: '75%',
paddingTop: '5px',
fontSize: '0.85rem',
marginRight: '8px',
};
};
export const getResultsTableStyles = (theme: GrafanaTheme2) => {
return {
table: css({
tableLayout: 'fixed',
borderRadius: theme.shape.radius.default,
width: '100%',
whiteSpace: 'normal',
td: {
padding: theme.spacing(1),
},
'td,th': {
minWidth: theme.spacing(3),
borderBottom: `1px solid ${theme.colors.border.weak}`,
},
}),
resultsPerPageWrapper: css({
display: 'flex',
row: css({
label: 'row',
borderBottom: `1px solid ${theme.colors.border.weak}`,
cursor: 'pointer',
'&:last-child': {
borderBottom: 0,
},
'&:hover': {
backgroundColor: theme.colors.background.secondary,
},
}),
tableHeaderPadding: css({
padding: '8px',
}),
matchHighLight: css({
background: 'inherit',
color: theme.components.textHighlight.text,
backgroundColor: theme.components.textHighlight.background,
}),
nameWidth: css({
width: '37.5%',
}),
nameOverflow: css({
overflowWrap: 'anywhere',
}),
typeWidth: css({
width: '15%',
}),
descriptionWidth: css({
width: '35%',
}),
stickyHeader: css({
position: 'sticky',
top: 0,
backgroundColor: theme.colors.background.primary,
}),
noResults: css({
textAlign: 'center',
color: theme.colors.text.secondary,
}),
tooltipSpace: css({
marginLeft: '4px',
}),
};
};
@@ -1,12 +1,9 @@
export const metricsModaltestIds = {
metricModal: 'metric-modal',
searchMetric: 'search-metric',
searchWithMetadata: 'search-with-metadata',
selectType: 'select-type',
metricCard: 'metric-card',
useMetric: 'use-metric',
searchPage: 'search-page',
resultsPerPage: 'results-per-page',
setUseBackend: 'set-use-backend',
showAdditionalSettings: 'show-additional-settings',
};
@@ -1,6 +1,5 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/metrics-modal/uFuzzy.ts
import uFuzzy from '@leeoniya/ufuzzy';
import { debounce as debounceLodash } from 'lodash';
const uf = new uFuzzy({
intraMode: 1,
@@ -10,37 +9,34 @@ const uf = new uFuzzy({
intraDel: 1,
});
function fuzzySearch(haystack: string[], query: string, dispatcher: (data: string[][]) => void) {
export const fuzzySearch = (haystack: string[], query: string) => {
const [idxs, info, order] = uf.search(haystack, query, 0, 1e5);
let haystackOrder: string[] = [];
let matchesSet: Set<string> = new Set();
if (idxs && order) {
/**
* get the fuzzy matches for hilighting
* @param part
* @param matched
*/
const mark = (part: string, matched: boolean) => {
if (matched) {
matchesSet.add(part);
}
};
// Iterate to create the order of needles(queries) and the matches
for (let i = 0; i < order.length; i++) {
let infoIdx = order[i];
/** Evaluate the match, get the matches for highlighting */
uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark);
/** Get the order */
haystackOrder.push(haystack[info.idx[infoIdx]]);
}
dispatcher([haystackOrder, [...matchesSet]]);
} else if (!query) {
dispatcher([[], []]);
if (!(idxs && order)) {
return [[], []];
}
}
/**
* get the fuzzy matches for highlighting
* @param part
* @param matched
*/
const mark = (part: string, matched: boolean) => {
if (matched) {
matchesSet.add(part);
}
};
export const debouncedFuzzySearch = debounceLodash(fuzzySearch, 300);
// Iterate to create the order of needles(queries) and the matches
for (let i = 0; i < order.length; i++) {
let infoIdx = order[i];
/** Evaluate the match, get the matches for highlighting */
uFuzzy.highlight(haystack[info.idx[infoIdx]], info.ranges[infoIdx], mark);
/** Get the order */
haystackOrder.push(haystack[info.idx[infoIdx]]);
}
return [haystackOrder, [...matchesSet]];
};
@@ -1,27 +0,0 @@
import { memo } from 'react';
import { NestedQueryList } from '../NestedQueryList';
import { QueryBuilderContent } from './QueryBuilderContent';
import { BaseQueryBuilderProps } from './types';
export const BaseQueryBuilder = memo<BaseQueryBuilderProps>((props) => {
const { query, datasource, onChange, onRunQuery, showExplain } = props;
return (
<>
<QueryBuilderContent {...props} />
{query.binaryQueries && query.binaryQueries.length > 0 && (
<NestedQueryList
query={query}
datasource={datasource}
onChange={onChange}
onRunQuery={onRunQuery}
showExplain={showExplain}
/>
)}
</>
);
});
BaseQueryBuilder.displayName = 'BaseQueryBuilder';
@@ -1,13 +0,0 @@
import { PanelData } from '@grafana/data';
import { PrometheusDatasource } from '../../../datasource';
import { PromVisualQuery } from '../../types';
export interface BaseQueryBuilderProps {
query: PromVisualQuery;
datasource: PrometheusDatasource;
onChange: (update: PromVisualQuery) => void;
onRunQuery: () => void;
data?: PanelData;
showExplain: boolean;
}
@@ -14,11 +14,6 @@ export interface PromVisualQuery {
labels: QueryBuilderLabelFilter[];
operations: QueryBuilderOperation[];
binaryQueries?: PromVisualQueryBinary[];
// metrics explorer additional settings
useBackend?: boolean;
disableTextWrap?: boolean;
includeNullMetadata?: boolean;
fullMetaSearch?: boolean;
}
export interface PromQueryModellerInterface {
-5
View File
@@ -15,11 +15,6 @@ export interface PromQuery extends GenPromQuery, DataQuery {
showingTable?: boolean;
hinting?: boolean;
interval?: string;
// store the metrics explorer additional settings
useBackend?: boolean;
disableTextWrap?: boolean;
fullMetaSearch?: boolean;
includeNullMetadata?: boolean;
fromExploreMetrics?: boolean;
}