diff --git a/.betterer.results b/.betterer.results index 9184482f4f4..3a380561f90 100644 --- a/.betterer.results +++ b/.betterer.results @@ -6442,6 +6442,8 @@ exports[`no gf-form usage`] = { [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], "packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx:5381": [ @@ -6965,6 +6967,8 @@ exports[`no gf-form usage`] = { [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], + [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"], [0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"] ], "public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx:5381": [ diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 284d62e14bb..aa2c5ac93dd 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -153,6 +153,7 @@ Experimental features might be changed or removed without prior notice. | `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint | | `cachingOptimizeSerializationMemoryUsage` | If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. | | `prometheusPromQAIL` | Prometheus and AI/ML to assist users in creating a query | +| `prometheusCodeModeMetricNamesSearch` | Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names | | `alertmanagerRemoteSecondary` | Enable Grafana to sync configuration and state with a remote Alertmanager. | | `alertmanagerRemotePrimary` | Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. | | `alertmanagerRemoteOnly` | Disable the internal Alertmanager and only use the external one defined. | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f35151dbf3f..946657f4b15 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -133,6 +133,7 @@ export interface FeatureToggles { panelTitleSearchInV1?: boolean; managedPluginsInstall?: boolean; prometheusPromQAIL?: boolean; + prometheusCodeModeMetricNamesSearch?: boolean; addFieldFromCalculationStatFunctions?: boolean; alertmanagerRemoteSecondary?: boolean; alertmanagerRemotePrimary?: boolean; diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index bd8d01da9de..f67c6874dcf 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -78,6 +78,7 @@ export const Components = { httpMethod: 'data-testid http method', exemplarsAddButton: 'data-testid Add exemplar config button', internalLinkSwitch: 'data-testid Internal link switch', + codeModeMetricNamesSuggestionLimit: 'data-testid code mode metric names suggestion limit', }, queryEditor: { // kickstart: '', see QueryBuilder queryPatterns below @@ -101,6 +102,7 @@ export const Components = { }, code: { queryField: 'data-testid prometheus query field', + metricsCountInfo: 'data-testid metrics count disclaimer', metricsBrowser: { openButton: 'data-testid open metrics browser', selectMetric: 'data-testid select a metric', diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx index 2404760aad8..2e5dbb1f52e 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx +++ b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx @@ -13,6 +13,7 @@ import { Monaco, monacoTypes, ReactMonacoEditor, useTheme2 } from '@grafana/ui'; import { Props } from './MonacoQueryFieldProps'; import { getOverrideServices } from './getOverrideServices'; import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider'; +import { DataProvider } from './monaco-completion-provider/data_provider'; import { placeHolderScopedVars, validateQuery } from './monaco-completion-provider/validation'; import { language, languageConfiguration } from './promql'; @@ -143,41 +144,10 @@ const MonacoQueryField = (props: Props) => { editor.onDidFocusEditorText(() => { isEditorFocused.set(true); }); - - // we construct a DataProvider object - const getHistory = () => - Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined)); - - const getAllMetricNames = () => { - const { metrics, metricsMetadata } = lpRef.current; - const result = metrics.map((m) => { - const metaItem = metricsMetadata?.[m]; - return { - name: m, - help: metaItem?.help ?? '', - type: metaItem?.type ?? '', - }; - }); - - return Promise.resolve(result); - }; - - const getAllLabelNames = () => Promise.resolve(lpRef.current.getLabelKeys()); - - const getLabelValues = (labelName: string) => lpRef.current.getLabelValues(labelName); - - const getSeriesValues = lpRef.current.getSeriesValues; - - const getSeriesLabels = lpRef.current.getSeriesLabels; - - const dataProvider = { - getHistory, - getAllMetricNames, - getAllLabelNames, - getLabelValues, - getSeriesValues, - getSeriesLabels, - }; + const dataProvider = new DataProvider({ + historyProvider: historyRef.current, + languageProvider: lpRef.current, + }); const completionProvider = getCompletionProvider(monaco, dataProvider); // completion-providers in monaco are not registered directly to editor-instances, diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts new file mode 100644 index 00000000000..26d425befa2 --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -0,0 +1,118 @@ +import { config } from '@grafana/runtime'; + +import { SUGGESTIONS_LIMIT } from '../../../language_provider'; +import { FUNCTIONS } from '../../../promql'; + +import { getCompletions } from './completions'; +import { DataProvider, DataProviderParams } from './data_provider'; +import type { Situation } from './situation'; + +const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', 'previous_metric_name_3']; +const dataProviderSettings = { + languageProvider: { + datasource: { + metricNamesAutocompleteSuggestionLimit: SUGGESTIONS_LIMIT, + }, + getLabelKeys: jest.fn(), + getLabelValues: jest.fn(), + getSeriesLabels: jest.fn(), + getSeriesValues: jest.fn(), + metrics: [], + metricsMetadata: {}, + }, + historyProvider: history.map((expr, idx) => ({ query: { expr, refId: 'some-ref' }, ts: idx })), +} as unknown as DataProviderParams; +let dataProvider = new DataProvider(dataProviderSettings); +const metrics = { + beyondLimit: Array.from(Array(SUGGESTIONS_LIMIT + 1), (_, i) => `metric_name_${i}`), + get atLimit() { + return this.beyondLimit.slice(0, SUGGESTIONS_LIMIT - 1); + }, +}; + +beforeEach(() => { + dataProvider = new DataProvider(dataProviderSettings); + jest.replaceProperty(config, 'featureToggles', { + prometheusCodeModeMetricNamesSearch: true, + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +type MetricNameSituation = Extract; +const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[]; + +function getSuggestionCountForSituation(situationType: MetricNameSituation, metricsCount: number): number { + const limitedMetricNamesCount = metricsCount < SUGGESTIONS_LIMIT ? metricsCount : SUGGESTIONS_LIMIT; + let suggestionsCount = limitedMetricNamesCount + FUNCTIONS.length; + + if (situationType === 'EMPTY') { + suggestionsCount += history.length; + } + + return suggestionsCount; +} + +describe.each(metricNameCompletionSituations)('metric name completions in situation %s', (situationType) => { + it('should return completions for all metric names when the number of metric names is at or below the limit', async () => { + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.atLimit); + const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.atLimit.length); + const situation: Situation = { + type: situationType, + }; + + // No text input + dataProvider.monacoSettings.setInputInRange(''); + let completions = await getCompletions(situation, dataProvider); + expect(completions).toHaveLength(expectedCompletionsCount); + + // With text input (use fuzzy search) + dataProvider.monacoSettings.setInputInRange('name_1'); + completions = await getCompletions(situation, dataProvider); + expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount); + }); + + it('should limit completions for metric names when the number of metric names is greater than the limit', async () => { + const situation: Situation = { + type: situationType, + }; + const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length); + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.beyondLimit); + + // No text input + dataProvider.monacoSettings.setInputInRange(''); + let completions = await getCompletions(situation, dataProvider); + expect(completions).toHaveLength(expectedCompletionsCount); + + // With text input (use fuzzy search) + dataProvider.monacoSettings.setInputInRange('name_1'); + completions = await getCompletions(situation, dataProvider); + expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount); + }); + + it('should enable autocomplete suggestions update when the number of metric names is greater than the limit', async () => { + const situation: Situation = { + type: situationType, + }; + + // Do not cross the metrics names threshold + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.atLimit); + dataProvider.monacoSettings.setInputInRange('name_1'); + await getCompletions(situation, dataProvider); + expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(false); + + // Cross the metric names threshold, without text input + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit); + dataProvider.monacoSettings.setInputInRange(''); + await getCompletions(situation, dataProvider); + expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true); + + // Cross the metric names threshold, with text input + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit); + dataProvider.monacoSettings.setInputInRange('name_1'); + await getCompletions(situation, dataProvider); + expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true); + }); +}); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts index fd9352bf28a..a092beac588 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -1,6 +1,11 @@ +import UFuzzy from '@leeoniya/ufuzzy'; + +import { config } from '@grafana/runtime'; + import { escapeLabelValueInExactSelector } from '../../../language_utils'; import { FUNCTIONS } from '../../../promql'; +import { DataProvider } from './data_provider'; import type { Label, Situation } from './situation'; import { NeverCaseError } from './util'; // FIXME: we should not load this from the "outside", but we cannot do that while we have the "old" query-field too @@ -16,26 +21,31 @@ type Completion = { triggerOnInsert?: boolean; }; -type Metric = { - name: string; - help: string; - type: string; -}; - -export type DataProvider = { - getHistory: () => Promise; - getAllMetricNames: () => Promise; - getAllLabelNames: () => Promise; - getLabelValues: (labelName: string) => Promise; - getSeriesValues: (name: string, match: string) => Promise; - getSeriesLabels: (selector: string, otherLabels: Label[]) => Promise; -}; +const metricNamesSearchClient = new UFuzzy({ intraMode: 1 }); // we order items like: history, functions, metrics +function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] { + let metricNames = dataProvider.getAllMetricNames(); -async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise { - const metrics = await dataProvider.getAllMetricNames(); - return metrics.map((metric) => ({ + if ( + config.featureToggles.prometheusCodeModeMetricNamesSearch && + metricNames.length > dataProvider.metricNamesSuggestionLimit + ) { + const { monacoSettings } = dataProvider; + monacoSettings.enableAutocompleteSuggestionsUpdate(); + + if (monacoSettings.inputInRange) { + metricNames = + metricNamesSearchClient + .filter(metricNames, monacoSettings.inputInRange) + ?.slice(0, dataProvider.metricNamesSuggestionLimit) + .map((idx) => metricNames[idx]) ?? []; + } else { + metricNames = metricNames.slice(0, dataProvider.metricNamesSuggestionLimit); + } + } + + return dataProvider.metricNamesToMetrics(metricNames).map((metric) => ({ type: 'METRIC_NAME', label: metric.name, insertText: metric.name, @@ -53,7 +63,8 @@ const FUNCTION_COMPLETIONS: Completion[] = FUNCTIONS.map((f) => ({ })); async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise { - const metricNames = await getAllMetricNamesCompletions(dataProvider); + const metricNames = getAllMetricNamesCompletions(dataProvider); + return [...FUNCTION_COMPLETIONS, ...metricNames]; } @@ -73,10 +84,10 @@ const DURATION_COMPLETIONS: Completion[] = [ insertText: text, })); -async function getAllHistoryCompletions(dataProvider: DataProvider): Promise { +function getAllHistoryCompletions(dataProvider: DataProvider): Completion[] { // function getAllHistoryCompletions(queryHistory: PromHistoryItem[]): Completion[] { // NOTE: the typescript types are wrong. historyItem.query.expr can be undefined - const allHistory = await dataProvider.getHistory(); + const allHistory = dataProvider.getHistory(); // FIXME: find a better history-limit return allHistory.slice(0, 10).map((expr) => ({ type: 'HISTORY', @@ -107,7 +118,7 @@ async function getLabelNames( ): Promise { if (metric === undefined && otherLabels.length === 0) { // if there is no filtering, we have to use a special endpoint - return dataProvider.getAllLabelNames(); + return Promise.resolve(dataProvider.getAllLabelNames()); } else { const selector = makeSelector(metric, otherLabels); return await dataProvider.getSeriesLabels(selector, otherLabels); @@ -176,19 +187,19 @@ async function getLabelValuesForMetricCompletions( })); } -export async function getCompletions(situation: Situation, dataProvider: DataProvider): Promise { +export function getCompletions(situation: Situation, dataProvider: DataProvider): Promise { switch (situation.type) { case 'IN_DURATION': - return DURATION_COMPLETIONS; + return Promise.resolve(DURATION_COMPLETIONS); case 'IN_FUNCTION': return getAllFunctionsAndMetricNamesCompletions(dataProvider); case 'AT_ROOT': { return getAllFunctionsAndMetricNamesCompletions(dataProvider); } case 'EMPTY': { - const metricNames = await getAllMetricNamesCompletions(dataProvider); - const historyCompletions = await getAllHistoryCompletions(dataProvider); - return [...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]; + const metricNames = getAllMetricNamesCompletions(dataProvider); + const historyCompletions = getAllHistoryCompletions(dataProvider); + return Promise.resolve([...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]); } case 'IN_LABEL_SELECTOR_NO_LABEL_NAME': return getLabelNamesForSelectorCompletions(situation.metricName, situation.otherLabels, dataProvider); diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/data_provider.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/data_provider.ts new file mode 100644 index 00000000000..48549442d64 --- /dev/null +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/data_provider.ts @@ -0,0 +1,116 @@ +import { HistoryItem } from '@grafana/data'; +import type { Monaco } from '@grafana/ui'; // used in TSDoc `@link` below + +import PromQlLanguageProvider from '../../../language_provider'; +import { PromQuery } from '../../../types'; + +export const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete'; + +export type SuggestionsIncompleteEvent = CustomEvent<{ + limit: number; + datasourceUid: string; +}>; + +export function isSuggestionsIncompleteEvent(e: Event): e is SuggestionsIncompleteEvent { + return ( + e.type === CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT && + 'detail' in e && + typeof e.detail === 'object' && + e.detail !== null && + 'limit' in e.detail && + 'datasourceUid' in e.detail + ); +} + +interface Metric { + name: string; + help: string; + type: string; +} + +export interface DataProviderParams { + languageProvider: PromQlLanguageProvider; + historyProvider: Array>; +} + +export class DataProvider { + readonly languageProvider: PromQlLanguageProvider; + readonly historyProvider: Array>; + readonly getSeriesLabels: typeof this.languageProvider.getSeriesLabels; + readonly getSeriesValues: typeof this.languageProvider.getSeriesValues; + readonly getAllLabelNames: typeof this.languageProvider.getLabelKeys; + readonly getLabelValues: typeof this.languageProvider.getLabelValues; + readonly metricNamesSuggestionLimit: number; + /** + * The text that's been typed so far within the current {@link Monaco.Range | Range}. + * + * @remarks + * This is useful with fuzzy searching items to provide as Monaco autocomplete suggestions. + */ + private inputInRange: string; + private suggestionsIncomplete: boolean; + + constructor(params: DataProviderParams) { + this.languageProvider = params.languageProvider; + this.historyProvider = params.historyProvider; + this.inputInRange = ''; + this.metricNamesSuggestionLimit = this.languageProvider.datasource.metricNamesAutocompleteSuggestionLimit; + this.suggestionsIncomplete = false; + this.getSeriesLabels = this.languageProvider.getSeriesLabels.bind(this.languageProvider); + this.getSeriesValues = this.languageProvider.getSeriesValues.bind(this.languageProvider); + this.getAllLabelNames = this.languageProvider.getLabelKeys.bind(this.languageProvider); + this.getLabelValues = this.languageProvider.getLabelValues.bind(this.languageProvider); + } + + getHistory(): string[] { + return this.historyProvider.map((h) => h.query.expr).filter(Boolean); + } + + getAllMetricNames(): string[] { + return this.languageProvider.metrics; + } + + metricNamesToMetrics(metricNames: string[]): Metric[] { + const { metricsMetadata } = this.languageProvider; + const result: Metric[] = metricNames.map((m) => { + const metaItem = metricsMetadata?.[m]; + return { + name: m, + help: metaItem?.help ?? '', + type: metaItem?.type ?? '', + }; + }); + + return result; + } + + private enableAutocompleteSuggestionsUpdate(): void { + this.suggestionsIncomplete = true; + dispatchEvent( + new CustomEvent(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, { + detail: { limit: this.metricNamesSuggestionLimit, datasourceUid: this.languageProvider.datasource.uid }, + }) + ); + } + + private setInputInRange(textInput: string): void { + this.inputInRange = textInput; + } + + get monacoSettings() { + return { + /** + * Enable autocomplete suggestions update on every input change. + * + * @remarks + * If fuzzy search is used in `getCompletions` to trim down results to improve performance, + * we need to instruct Monaco to update the completions on every input change, so that the + * completions reflect the current input. + */ + enableAutocompleteSuggestionsUpdate: this.enableAutocompleteSuggestionsUpdate.bind(this), + inputInRange: this.inputInRange, + setInputInRange: this.setInputInRange.bind(this), + suggestionsIncomplete: this.suggestionsIncomplete, + }; + } +} diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts index f9b6a2e2b47..6c1037df321 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/index.ts @@ -1,6 +1,7 @@ import type { Monaco, monacoTypes } from '@grafana/ui'; -import { CompletionType, DataProvider, getCompletions } from './completions'; +import { CompletionType, getCompletions } from './completions'; +import { DataProvider } from './data_provider'; import { getSituation } from './situation'; import { NeverCaseError } from './util'; @@ -69,6 +70,7 @@ export function getCompletionProvider( column: position.column, lineNumber: position.lineNumber, }; + dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range)); // Check to see if the browser supports window.getSelection() if (window.getSelection) { @@ -82,6 +84,7 @@ export function getCompletionProvider( const offset = model.getOffsetAt(positionClone); const situation = getSituation(model.getValue(), offset); const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]); + return completionsPromise.then((items) => { // monaco by-default alphabetically orders the items. // to stop it, we use a number-as-string sortkey, @@ -102,7 +105,7 @@ export function getCompletionProvider( } : undefined, })); - return { suggestions }; + return { suggestions, incomplete: dataProvider.monacoSettings.suggestionsIncomplete }; }); }; diff --git a/packages/grafana-prometheus/src/configuration/PromSettings.test.tsx b/packages/grafana-prometheus/src/configuration/PromSettings.test.tsx index a030646ccdb..af9b99bd4da 100644 --- a/packages/grafana-prometheus/src/configuration/PromSettings.test.tsx +++ b/packages/grafana-prometheus/src/configuration/PromSettings.test.tsx @@ -1,11 +1,19 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import React, { SyntheticEvent } from 'react'; import { SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; -import { getValueFromEventItem, PromSettings } from './PromSettings'; +import { countError, getValueFromEventItem, PromSettings } from './PromSettings'; import { createDefaultConfigOptions } from './mocks'; +beforeEach(() => { + jest.replaceProperty(config, 'featureToggles', { + prometheusCodeModeMetricNamesSearch: true, + }); +}); + describe('PromSettings', () => { describe('getValueFromEventItem', () => { describe('when called with undefined', () => { @@ -61,5 +69,46 @@ describe('PromSettings', () => { render( {}} options={options} />); expect(screen.getByText('GET')).toBeInTheDocument(); }); + it('should show a valid metric name count if codeModeMetricNamesSuggestionLimit is configured correctly', () => { + const options = defaultProps; + + const { getByTestId, queryByText } = render( {}} options={options} />); + const input = getByTestId( + selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit + ); + + // Non-negative integer + fireEvent.change(input, { target: { value: '3000' } }); + fireEvent.blur(input); + expect(queryByText(countError)).not.toBeInTheDocument(); + + // Non-negative integer with scientific notation + fireEvent.change(input, { target: { value: '1e5' } }); + fireEvent.blur(input); + expect(queryByText(countError)).not.toBeInTheDocument(); + + // Non-negative integer with decimal scientific notation + fireEvent.change(input, { target: { value: '1.4e4' } }); + fireEvent.blur(input); + expect(queryByText(countError)).not.toBeInTheDocument(); + }); + it('should show the expected error when an invalid value is provided for codeModeMetricNamesSuggestionLimit', () => { + const options = defaultProps; + + const { getByTestId, queryByText } = render( {}} options={options} />); + const input = getByTestId( + selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit + ); + + // No negative values + fireEvent.change(input, { target: { value: '-50' } }); + fireEvent.blur(input); + expect(queryByText(countError)).toBeInTheDocument(); + + // No negative values with scientific notation + fireEvent.change(input, { target: { value: '-5e5' } }); + fireEvent.blur(input); + expect(queryByText(countError)).toBeInTheDocument(); + }); }); }); diff --git a/packages/grafana-prometheus/src/configuration/PromSettings.tsx b/packages/grafana-prometheus/src/configuration/PromSettings.tsx index d2b01bfc19d..d9a0b7abc64 100644 --- a/packages/grafana-prometheus/src/configuration/PromSettings.tsx +++ b/packages/grafana-prometheus/src/configuration/PromSettings.tsx @@ -8,8 +8,10 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { ConfigSubSection } from '@grafana/experimental'; +import { config } from '@grafana/runtime'; import { InlineField, Input, Select, Switch, useTheme2 } from '@grafana/ui'; +import { SUGGESTIONS_LIMIT } from '../language_provider'; import { QueryEditorMode } from '../querybuilder/shared/types'; import { defaultPrometheusQueryOverlapWindow } from '../querycache/QueryCache'; import { PromApplication, PrometheusCacheLevel, PromOptions } from '../types'; @@ -52,7 +54,10 @@ export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/; // multiple duration input export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/; +export const NON_NEGATIVE_INTEGER_REGEX = /^(0|[1-9]\d*)(\.\d+)?(e\+?\d+)?$/; // non-negative integers, including scientific notation + const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s'; +export const countError = 'Value is not valid, you can use non-negative integers, including scientific notation'; export const PromSettings = (props: Props) => { const { options, onOptionsChange } = props; @@ -78,6 +83,14 @@ export const PromSettings = (props: Props) => { incrementalQueryOverlapWindow: '', }); + type ValidCount = { + codeModeMetricNamesSuggestionLimit: string; + }; + + const [validCount, updateValidCount] = useState({ + codeModeMetricNamesSuggestionLimit: '', + }); + return ( <> @@ -301,6 +314,49 @@ export const PromSettings = (props: Props) => { + {config.featureToggles.prometheusCodeModeMetricNamesSearch && ( +
+
+ + The maximum number of metric names that may appear as autocomplete suggestions in the query + editor's Code mode. + + } + interactive={true} + disabled={options.readOnly} + > + <> + + updateValidCount({ + ...validCount, + codeModeMetricNamesSuggestionLimit: e.currentTarget.value, + }) + } + data-testid={ + selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit + } + /> + {validateInput( + validCount.codeModeMetricNamesSuggestionLimit, + NON_NEGATIVE_INTEGER_REGEX, + countError + )} + + +
+
+ )} +
; + metricNamesAutocompleteSuggestionLimit: number; constructor( instanceSettings: DataSourceInstanceSettings, @@ -127,6 +128,8 @@ export class PrometheusDatasource this.variables = new PrometheusVariableSupport(this, this.templateSrv); this.exemplarsAvailable = true; this.cacheLevel = instanceSettings.jsonData.cacheLevel ?? PrometheusCacheLevel.Low; + this.metricNamesAutocompleteSuggestionLimit = + instanceSettings.jsonData.codeModeMetricNamesSuggestionLimit ?? SUGGESTIONS_LIMIT; this.cache = new QueryCache({ getTargetSignature: this.getPrometheusTargetSignature.bind(this), diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.test.tsx new file mode 100644 index 00000000000..2cc33275317 --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.test.tsx @@ -0,0 +1,161 @@ +import { render, screen, fireEvent, createEvent } from '@testing-library/react'; +import { cloneDeep, defaultsDeep } from 'lodash'; +import React from 'react'; + +import { PluginMeta, PluginType } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; + +import { CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT } from '../../components/monaco-query-field/monaco-completion-provider/data_provider'; +import { PromQueryEditorProps } from '../../components/types'; +import { PrometheusDatasource } from '../../datasource'; +import PromQlLanguageProvider from '../../language_provider'; +import { EmptyLanguageProviderMock } from '../../language_provider.mock'; +import { PromQuery } from '../../types'; +import { QueryEditorMode } from '../shared/types'; + +import { PromQueryEditorSelector } from './PromQueryEditorSelector'; + +beforeEach(() => { + jest.replaceProperty(config, 'featureToggles', { + prometheusCodeModeMetricNamesSearch: true, + }); +}); + +// We need to mock this because it seems jest has problem importing monaco in tests +jest.mock('../../components/monaco-query-field/MonacoQueryFieldWrapper', () => { + return { + MonacoQueryFieldWrapper: () => { + return 'MonacoQueryFieldWrapper'; + }, + }; +}); + +jest.mock('../../gcopypaste/app/core/store', () => { + return { + get() { + return undefined; + }, + set() {}, + getObject(key: string, defaultValue: unknown) { + return defaultValue; + }, + }; +}); + +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + }; +}); + +const defaultQuery = { + refId: 'A', + expr: 'metric{label1="foo", label2="bar"}', +}; + +const defaultMeta: PluginMeta = { + id: '', + name: '', + type: PluginType.datasource, + info: { + author: { + name: 'tester', + }, + description: 'testing', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + module: '', + baseUrl: '', +}; + +const getDefaultDatasource = (jsonDataOverrides = {}) => + new PrometheusDatasource( + { + id: 1, + uid: 'myDataSourceUid', + type: 'prometheus', + name: 'prom-test', + access: 'proxy', + url: '', + jsonData: jsonDataOverrides, + meta: defaultMeta, + readOnly: false, + }, + undefined, + new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider + ); + +const defaultProps = { + datasource: getDefaultDatasource(), + query: defaultQuery, + onRunQuery: () => {}, + onChange: () => {}, +}; + +const autocompleteInfoSelector = selectors.components.DataSource.Prometheus.queryEditor.code.metricsCountInfo; + +describe('PromQueryEditorSelector', () => { + it('does not show autocomplete info when the code editor first displays', async () => { + const { queryByTestId } = renderWithCodeMode(); + expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument(); + expect(queryByTestId(autocompleteInfoSelector)).not.toBeInTheDocument(); + }); + + it('shows autocomplete info when the expected event fires', async () => { + const { findByTestId } = renderWithCodeMode(); + fireEvent( + window, + createEvent( + CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, + window, + { + detail: { limit: 100, datasourceUid: 'myDataSourceUid' }, + }, + { EventType: 'CustomEvent' } + ) + ); + expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument(); + const autocompleteInfo = await findByTestId(autocompleteInfoSelector); + expect(autocompleteInfo).toBeInTheDocument(); + }); + + it('does not show autocomplete info when the triggering event refers to a different data source', async () => { + const { queryByTestId } = renderWithCodeMode(); + fireEvent( + window, + createEvent( + CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, + window, + { + detail: { limit: 100, datasourceUid: 'theWrongUid' }, + }, + { EventType: 'CustomEvent' } + ) + ); + expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument(); + const autocompleteInfo = await queryByTestId(autocompleteInfoSelector); + expect(autocompleteInfo).not.toBeInTheDocument(); + }); +}); + +function renderWithCodeMode() { + return renderWithProps({ editorMode: QueryEditorMode.Code, expr: 'my_metric' }); +} + +function renderWithProps(overrides?: Partial, componentProps: Partial = {}) { + const query = defaultsDeep(overrides ?? {}, cloneDeep(defaultQuery)); + const onChange = jest.fn(); + + const allProps = { ...defaultProps, ...componentProps }; + const stuff = render(); + return { onChange, ...stuff }; +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.tsx new file mode 100644 index 00000000000..d64c77c946e --- /dev/null +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.tsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect, useCallback } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; +import { IconButton, Text, Stack } from '@grafana/ui'; + +import { + CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, + isSuggestionsIncompleteEvent, +} from '../../components/monaco-query-field/monaco-completion-provider/data_provider'; +import { PromQueryEditorProps } from '../../components/types'; +import { QueryEditorMode } from '../shared/types'; + +interface Props { + datasourceUid: PromQueryEditorProps['datasource']['uid']; + editorMode: QueryEditorMode; +} + +export function PromQueryCodeEditorAutocompleteInfo(props: Readonly) { + const [autocompleteLimit, setAutocompleteLimit] = useState('n'); + const [autocompleteLimitExceeded, setAutocompleteLimitExceeded] = useState(false); + const handleSuggestionsIncompleteEvent = useCallback( + (e: Event) => { + if (!isSuggestionsIncompleteEvent(e)) { + return; + } + + if (e.detail.datasourceUid === props.datasourceUid) { + setAutocompleteLimitExceeded(true); + setAutocompleteLimit(e.detail.limit.toString()); + } + }, + [props.datasourceUid] + ); + + useEffect(() => { + addEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent); + + return () => { + removeEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent); + }; + }, [handleSuggestionsIncompleteEvent]); + + const showCodeModeAutocompleteDisclaimer = (): boolean => { + return ( + Boolean(config.featureToggles.prometheusCodeModeMetricNamesSearch) && + props.editorMode === QueryEditorMode.Code && + autocompleteLimitExceeded + ); + }; + + if (!showCodeModeAutocompleteDisclaimer()) { + return null; + } + + return ( +
+ + + Autocomplete suggestions limited + + + +
+ ); +} diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx index f85090e0cad..b46ef45c6f0 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx @@ -21,6 +21,7 @@ import { changeEditorMode, getQueryWithDefaults } from '../state'; import { PromQueryBuilderContainer } from './PromQueryBuilderContainer'; import { PromQueryBuilderOptions } from './PromQueryBuilderOptions'; import { PromQueryCodeEditor } from './PromQueryCodeEditor'; +import { PromQueryCodeEditorAutocompleteInfo } from './PromQueryCodeEditorAutocompleteInfo'; export const FORMAT_OPTIONS: Array> = [ { label: 'Time series', value: 'time_series' }, @@ -138,6 +139,7 @@ export const PromQueryEditorSelector = React.memo((props) => { Run queries )} +
diff --git a/packages/grafana-prometheus/src/types.ts b/packages/grafana-prometheus/src/types.ts index f3c5730b380..f6837388e05 100644 --- a/packages/grafana-prometheus/src/types.ts +++ b/packages/grafana-prometheus/src/types.ts @@ -51,6 +51,7 @@ export interface PromOptions extends DataSourceJsonData { disableRecordingRules?: boolean; sigV4Auth?: boolean; oauthPassThru?: boolean; + codeModeMetricNamesSuggestionLimit?: number; } export type ExemplarTraceIdDestination = { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index fdcef526bd8..e0f642bd789 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -859,6 +859,13 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityMetricsSquad, }, + { + Name: "prometheusCodeModeMetricNamesSearch", + Description: "Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names", + FrontendOnly: true, + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityMetricsSquad, + }, { Name: "addFieldFromCalculationStatFunctions", Description: "Add cumulative and window functions to the add field from calculation transformation", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index a54bc887b3a..907e45a5c34 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -114,6 +114,7 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e panelTitleSearchInV1,experimental,@grafana/backend-platform,true,false,false managedPluginsInstall,GA,@grafana/plugins-platform-backend,false,false,false prometheusPromQAIL,experimental,@grafana/observability-metrics,false,false,true +prometheusCodeModeMetricNamesSearch,experimental,@grafana/observability-metrics,false,false,true addFieldFromCalculationStatFunctions,preview,@grafana/dataviz-squad,false,false,true alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 475b996c633..89688915fe1 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -467,6 +467,10 @@ const ( // Prometheus and AI/ML to assist users in creating a query FlagPrometheusPromQAIL = "prometheusPromQAIL" + // FlagPrometheusCodeModeMetricNamesSearch + // Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names + FlagPrometheusCodeModeMetricNamesSearch = "prometheusCodeModeMetricNamesSearch" + // FlagAddFieldFromCalculationStatFunctions // Add cumulative and window functions to the add field from calculation transformation FlagAddFieldFromCalculationStatFunctions = "addFieldFromCalculationStatFunctions" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index b54a2456ba7..139dff08e7e 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2107,6 +2107,19 @@ "hideFromAdminPage": true, "hideFromDocs": true } + }, + { + "metadata": { + "name": "prometheusCodeModeMetricNamesSearch", + "resourceVersion": "1712241392690", + "creationTimestamp": "2024-04-04T14:36:32Z" + }, + "spec": { + "description": "Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names", + "stage": "experimental", + "codeowner": "@grafana/observability-metrics", + "frontend": true + } } ] } \ No newline at end of file diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryField.tsx b/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryField.tsx index 66a505bcfa2..5d25f91e670 100644 --- a/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryField.tsx +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/MonacoQueryField.tsx @@ -17,6 +17,7 @@ import { import { Props } from './MonacoQueryFieldProps'; import { getOverrideServices } from './getOverrideServices'; import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider'; +import { DataProvider } from './monaco-completion-provider/data_provider'; const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = { codeLens: false, @@ -145,41 +146,10 @@ const MonacoQueryField = (props: Props) => { editor.onDidFocusEditorText(() => { isEditorFocused.set(true); }); - - // we construct a DataProvider object - const getHistory = () => - Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined)); - - const getAllMetricNames = () => { - const { metrics, metricsMetadata } = lpRef.current; - const result = metrics.map((m) => { - const metaItem = metricsMetadata?.[m]; - return { - name: m, - help: metaItem?.help ?? '', - type: metaItem?.type ?? '', - }; - }); - - return Promise.resolve(result); - }; - - const getAllLabelNames = () => Promise.resolve(lpRef.current.getLabelKeys()); - - const getLabelValues = (labelName: string) => lpRef.current.getLabelValues(labelName); - - const getSeriesValues = lpRef.current.getSeriesValues; - - const getSeriesLabels = lpRef.current.getSeriesLabels; - - const dataProvider = { - getHistory, - getAllMetricNames, - getAllLabelNames, - getLabelValues, - getSeriesValues, - getSeriesLabels, - }; + const dataProvider = new DataProvider({ + historyProvider: historyRef.current, + languageProvider: lpRef.current, + }); const completionProvider = getCompletionProvider(monaco, dataProvider); // completion-providers in monaco are not registered directly to editor-instances, diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.test.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.test.ts new file mode 100644 index 00000000000..26d425befa2 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.test.ts @@ -0,0 +1,118 @@ +import { config } from '@grafana/runtime'; + +import { SUGGESTIONS_LIMIT } from '../../../language_provider'; +import { FUNCTIONS } from '../../../promql'; + +import { getCompletions } from './completions'; +import { DataProvider, DataProviderParams } from './data_provider'; +import type { Situation } from './situation'; + +const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', 'previous_metric_name_3']; +const dataProviderSettings = { + languageProvider: { + datasource: { + metricNamesAutocompleteSuggestionLimit: SUGGESTIONS_LIMIT, + }, + getLabelKeys: jest.fn(), + getLabelValues: jest.fn(), + getSeriesLabels: jest.fn(), + getSeriesValues: jest.fn(), + metrics: [], + metricsMetadata: {}, + }, + historyProvider: history.map((expr, idx) => ({ query: { expr, refId: 'some-ref' }, ts: idx })), +} as unknown as DataProviderParams; +let dataProvider = new DataProvider(dataProviderSettings); +const metrics = { + beyondLimit: Array.from(Array(SUGGESTIONS_LIMIT + 1), (_, i) => `metric_name_${i}`), + get atLimit() { + return this.beyondLimit.slice(0, SUGGESTIONS_LIMIT - 1); + }, +}; + +beforeEach(() => { + dataProvider = new DataProvider(dataProviderSettings); + jest.replaceProperty(config, 'featureToggles', { + prometheusCodeModeMetricNamesSearch: true, + }); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +type MetricNameSituation = Extract; +const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[]; + +function getSuggestionCountForSituation(situationType: MetricNameSituation, metricsCount: number): number { + const limitedMetricNamesCount = metricsCount < SUGGESTIONS_LIMIT ? metricsCount : SUGGESTIONS_LIMIT; + let suggestionsCount = limitedMetricNamesCount + FUNCTIONS.length; + + if (situationType === 'EMPTY') { + suggestionsCount += history.length; + } + + return suggestionsCount; +} + +describe.each(metricNameCompletionSituations)('metric name completions in situation %s', (situationType) => { + it('should return completions for all metric names when the number of metric names is at or below the limit', async () => { + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.atLimit); + const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.atLimit.length); + const situation: Situation = { + type: situationType, + }; + + // No text input + dataProvider.monacoSettings.setInputInRange(''); + let completions = await getCompletions(situation, dataProvider); + expect(completions).toHaveLength(expectedCompletionsCount); + + // With text input (use fuzzy search) + dataProvider.monacoSettings.setInputInRange('name_1'); + completions = await getCompletions(situation, dataProvider); + expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount); + }); + + it('should limit completions for metric names when the number of metric names is greater than the limit', async () => { + const situation: Situation = { + type: situationType, + }; + const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length); + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.beyondLimit); + + // No text input + dataProvider.monacoSettings.setInputInRange(''); + let completions = await getCompletions(situation, dataProvider); + expect(completions).toHaveLength(expectedCompletionsCount); + + // With text input (use fuzzy search) + dataProvider.monacoSettings.setInputInRange('name_1'); + completions = await getCompletions(situation, dataProvider); + expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount); + }); + + it('should enable autocomplete suggestions update when the number of metric names is greater than the limit', async () => { + const situation: Situation = { + type: situationType, + }; + + // Do not cross the metrics names threshold + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.atLimit); + dataProvider.monacoSettings.setInputInRange('name_1'); + await getCompletions(situation, dataProvider); + expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(false); + + // Cross the metric names threshold, without text input + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit); + dataProvider.monacoSettings.setInputInRange(''); + await getCompletions(situation, dataProvider); + expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true); + + // Cross the metric names threshold, with text input + jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit); + dataProvider.monacoSettings.setInputInRange('name_1'); + await getCompletions(situation, dataProvider); + expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.ts index 7297ea1e6c6..d38f7a18059 100644 --- a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.ts +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/completions.ts @@ -1,6 +1,11 @@ +import UFuzzy from '@leeoniya/ufuzzy'; + +import { config } from '@grafana/runtime'; + import { escapeLabelValueInExactSelector } from '../../../language_utils'; import { FUNCTIONS } from '../../../promql'; +import { DataProvider } from './data_provider'; import type { Situation, Label } from './situation'; import { NeverCaseError } from './util'; // FIXME: we should not load this from the "outside", but we cannot do that while we have the "old" query-field too @@ -16,26 +21,31 @@ type Completion = { triggerOnInsert?: boolean; }; -type Metric = { - name: string; - help: string; - type: string; -}; - -export type DataProvider = { - getHistory: () => Promise; - getAllMetricNames: () => Promise; - getAllLabelNames: () => Promise; - getLabelValues: (labelName: string) => Promise; - getSeriesValues: (name: string, match: string) => Promise; - getSeriesLabels: (selector: string, otherLabels: Label[]) => Promise; -}; +const metricNamesSearchClient = new UFuzzy({ intraMode: 1 }); // we order items like: history, functions, metrics +function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] { + let metricNames = dataProvider.getAllMetricNames(); -async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise { - const metrics = await dataProvider.getAllMetricNames(); - return metrics.map((metric) => ({ + if ( + config.featureToggles.prometheusCodeModeMetricNamesSearch && + metricNames.length > dataProvider.metricNamesSuggestionLimit + ) { + const { monacoSettings } = dataProvider; + monacoSettings.enableAutocompleteSuggestionsUpdate(); + + if (monacoSettings.inputInRange) { + metricNames = + metricNamesSearchClient + .filter(metricNames, monacoSettings.inputInRange) + ?.slice(0, dataProvider.metricNamesSuggestionLimit) + .map((idx) => metricNames[idx]) ?? []; + } else { + metricNames = metricNames.slice(0, dataProvider.metricNamesSuggestionLimit); + } + } + + return dataProvider.metricNamesToMetrics(metricNames).map((metric) => ({ type: 'METRIC_NAME', label: metric.name, insertText: metric.name, @@ -53,7 +63,7 @@ const FUNCTION_COMPLETIONS: Completion[] = FUNCTIONS.map((f) => ({ })); async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise { - const metricNames = await getAllMetricNamesCompletions(dataProvider); + const metricNames = getAllMetricNamesCompletions(dataProvider); return [...FUNCTION_COMPLETIONS, ...metricNames]; } @@ -73,10 +83,10 @@ const DURATION_COMPLETIONS: Completion[] = [ insertText: text, })); -async function getAllHistoryCompletions(dataProvider: DataProvider): Promise { +function getAllHistoryCompletions(dataProvider: DataProvider): Completion[] { // function getAllHistoryCompletions(queryHistory: PromHistoryItem[]): Completion[] { // NOTE: the typescript types are wrong. historyItem.query.expr can be undefined - const allHistory = await dataProvider.getHistory(); + const allHistory = dataProvider.getHistory(); // FIXME: find a better history-limit return allHistory.slice(0, 10).map((expr) => ({ type: 'HISTORY', @@ -107,7 +117,7 @@ async function getLabelNames( ): Promise { if (metric === undefined && otherLabels.length === 0) { // if there is no filtering, we have to use a special endpoint - return dataProvider.getAllLabelNames(); + return Promise.resolve(dataProvider.getAllLabelNames()); } else { const selector = makeSelector(metric, otherLabels); return await dataProvider.getSeriesLabels(selector, otherLabels); @@ -175,19 +185,19 @@ async function getLabelValuesForMetricCompletions( })); } -export async function getCompletions(situation: Situation, dataProvider: DataProvider): Promise { +export function getCompletions(situation: Situation, dataProvider: DataProvider): Promise { switch (situation.type) { case 'IN_DURATION': - return DURATION_COMPLETIONS; + return Promise.resolve(DURATION_COMPLETIONS); case 'IN_FUNCTION': return getAllFunctionsAndMetricNamesCompletions(dataProvider); case 'AT_ROOT': { return getAllFunctionsAndMetricNamesCompletions(dataProvider); } case 'EMPTY': { - const metricNames = await getAllMetricNamesCompletions(dataProvider); - const historyCompletions = await getAllHistoryCompletions(dataProvider); - return [...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]; + const metricNames = getAllMetricNamesCompletions(dataProvider); + const historyCompletions = getAllHistoryCompletions(dataProvider); + return Promise.resolve([...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]); } case 'IN_LABEL_SELECTOR_NO_LABEL_NAME': return getLabelNamesForSelectorCompletions(situation.metricName, situation.otherLabels, dataProvider); diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/data_provider.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/data_provider.ts new file mode 100644 index 00000000000..48549442d64 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/data_provider.ts @@ -0,0 +1,116 @@ +import { HistoryItem } from '@grafana/data'; +import type { Monaco } from '@grafana/ui'; // used in TSDoc `@link` below + +import PromQlLanguageProvider from '../../../language_provider'; +import { PromQuery } from '../../../types'; + +export const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete'; + +export type SuggestionsIncompleteEvent = CustomEvent<{ + limit: number; + datasourceUid: string; +}>; + +export function isSuggestionsIncompleteEvent(e: Event): e is SuggestionsIncompleteEvent { + return ( + e.type === CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT && + 'detail' in e && + typeof e.detail === 'object' && + e.detail !== null && + 'limit' in e.detail && + 'datasourceUid' in e.detail + ); +} + +interface Metric { + name: string; + help: string; + type: string; +} + +export interface DataProviderParams { + languageProvider: PromQlLanguageProvider; + historyProvider: Array>; +} + +export class DataProvider { + readonly languageProvider: PromQlLanguageProvider; + readonly historyProvider: Array>; + readonly getSeriesLabels: typeof this.languageProvider.getSeriesLabels; + readonly getSeriesValues: typeof this.languageProvider.getSeriesValues; + readonly getAllLabelNames: typeof this.languageProvider.getLabelKeys; + readonly getLabelValues: typeof this.languageProvider.getLabelValues; + readonly metricNamesSuggestionLimit: number; + /** + * The text that's been typed so far within the current {@link Monaco.Range | Range}. + * + * @remarks + * This is useful with fuzzy searching items to provide as Monaco autocomplete suggestions. + */ + private inputInRange: string; + private suggestionsIncomplete: boolean; + + constructor(params: DataProviderParams) { + this.languageProvider = params.languageProvider; + this.historyProvider = params.historyProvider; + this.inputInRange = ''; + this.metricNamesSuggestionLimit = this.languageProvider.datasource.metricNamesAutocompleteSuggestionLimit; + this.suggestionsIncomplete = false; + this.getSeriesLabels = this.languageProvider.getSeriesLabels.bind(this.languageProvider); + this.getSeriesValues = this.languageProvider.getSeriesValues.bind(this.languageProvider); + this.getAllLabelNames = this.languageProvider.getLabelKeys.bind(this.languageProvider); + this.getLabelValues = this.languageProvider.getLabelValues.bind(this.languageProvider); + } + + getHistory(): string[] { + return this.historyProvider.map((h) => h.query.expr).filter(Boolean); + } + + getAllMetricNames(): string[] { + return this.languageProvider.metrics; + } + + metricNamesToMetrics(metricNames: string[]): Metric[] { + const { metricsMetadata } = this.languageProvider; + const result: Metric[] = metricNames.map((m) => { + const metaItem = metricsMetadata?.[m]; + return { + name: m, + help: metaItem?.help ?? '', + type: metaItem?.type ?? '', + }; + }); + + return result; + } + + private enableAutocompleteSuggestionsUpdate(): void { + this.suggestionsIncomplete = true; + dispatchEvent( + new CustomEvent(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, { + detail: { limit: this.metricNamesSuggestionLimit, datasourceUid: this.languageProvider.datasource.uid }, + }) + ); + } + + private setInputInRange(textInput: string): void { + this.inputInRange = textInput; + } + + get monacoSettings() { + return { + /** + * Enable autocomplete suggestions update on every input change. + * + * @remarks + * If fuzzy search is used in `getCompletions` to trim down results to improve performance, + * we need to instruct Monaco to update the completions on every input change, so that the + * completions reflect the current input. + */ + enableAutocompleteSuggestionsUpdate: this.enableAutocompleteSuggestionsUpdate.bind(this), + inputInRange: this.inputInRange, + setInputInRange: this.setInputInRange.bind(this), + suggestionsIncomplete: this.suggestionsIncomplete, + }; + } +} diff --git a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/index.ts b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/index.ts index c9dc43ecbbe..6c1037df321 100644 --- a/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/index.ts +++ b/public/app/plugins/datasource/prometheus/components/monaco-query-field/monaco-completion-provider/index.ts @@ -1,6 +1,7 @@ import type { Monaco, monacoTypes } from '@grafana/ui'; -import { getCompletions, DataProvider, CompletionType } from './completions'; +import { CompletionType, getCompletions } from './completions'; +import { DataProvider } from './data_provider'; import { getSituation } from './situation'; import { NeverCaseError } from './util'; @@ -69,6 +70,7 @@ export function getCompletionProvider( column: position.column, lineNumber: position.lineNumber, }; + dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range)); // Check to see if the browser supports window.getSelection() if (window.getSelection) { @@ -82,6 +84,7 @@ export function getCompletionProvider( const offset = model.getOffsetAt(positionClone); const situation = getSituation(model.getValue(), offset); const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]); + return completionsPromise.then((items) => { // monaco by-default alphabetically orders the items. // to stop it, we use a number-as-string sortkey, @@ -102,7 +105,7 @@ export function getCompletionProvider( } : undefined, })); - return { suggestions }; + return { suggestions, incomplete: dataProvider.monacoSettings.suggestionsIncomplete }; }); }; diff --git a/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx b/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx index aea4755989e..9f32571f5ec 100644 --- a/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/PromSettings.test.tsx @@ -1,14 +1,22 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import React, { SyntheticEvent } from 'react'; import { Provider } from 'react-redux'; import { SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { configureStore } from '../../../../store/configureStore'; -import { getValueFromEventItem, PromSettings } from './PromSettings'; +import { countError, getValueFromEventItem, PromSettings } from './PromSettings'; import { createDefaultConfigOptions } from './mocks'; +beforeEach(() => { + jest.replaceProperty(config, 'featureToggles', { + prometheusCodeModeMetricNamesSearch: true, + }); +}); + describe('PromSettings', () => { describe('getValueFromEventItem', () => { describe('when called with undefined', () => { @@ -79,5 +87,56 @@ describe('PromSettings', () => { ); expect(screen.getByText('GET')).toBeInTheDocument(); }); + it('should show a valid metric name count if codeModeMetricNamesSuggestionLimit is configured correctly', () => { + const options = defaultProps; + const store = configureStore(); + + const { getByTestId, queryByText } = render( + + {}} options={options} /> + + ); + const input = getByTestId( + selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit + ); + + // Non-negative integer + fireEvent.change(input, { target: { value: '3000' } }); + fireEvent.blur(input); + expect(queryByText(countError)).not.toBeInTheDocument(); + + // Non-negative integer with scientific notation + fireEvent.change(input, { target: { value: '1e5' } }); + fireEvent.blur(input); + expect(queryByText(countError)).not.toBeInTheDocument(); + + // Non-negative integer with decimal scientific notation + fireEvent.change(input, { target: { value: '1.4e4' } }); + fireEvent.blur(input); + expect(queryByText(countError)).not.toBeInTheDocument(); + }); + it('should show the expected error when an invalid value is provided for codeModeMetricNamesSuggestionLimit', () => { + const options = defaultProps; + const store = configureStore(); + + const { getByTestId, queryByText } = render( + + {}} options={options} /> + + ); + const input = getByTestId( + selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit + ); + + // No negative values + fireEvent.change(input, { target: { value: '-50' } }); + fireEvent.blur(input); + expect(queryByText(countError)).toBeInTheDocument(); + + // No negative values with scientific notation + fireEvent.change(input, { target: { value: '-5e5' } }); + fireEvent.blur(input); + expect(queryByText(countError)).toBeInTheDocument(); + }); }); }); diff --git a/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx index 7d11f4d9bad..7cc4677239a 100644 --- a/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/PromSettings.tsx @@ -10,10 +10,12 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { ConfigSubSection } from '@grafana/experimental'; +import { config } from '@grafana/runtime'; import { getBackendSrv } from '@grafana/runtime/src'; import { InlineField, Input, Select, Switch, useTheme2 } from '@grafana/ui'; import { useUpdateDatasource } from '../../../../features/datasources/state'; +import { SUGGESTIONS_LIMIT } from '../language_provider'; import { QueryEditorMode } from '../querybuilder/shared/types'; import { defaultPrometheusQueryOverlapWindow } from '../querycache/QueryCache'; import { PromApplication, PromBuildInfoResponse, PrometheusCacheLevel, PromOptions } from '../types'; @@ -56,7 +58,10 @@ export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/; // multiple duration input export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/; +export const NON_NEGATIVE_INTEGER_REGEX = /^(0|[1-9]\d*)(\.\d+)?(e\+?\d+)?$/; // non-negative integers, including scientific notation + const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s'; +export const countError = 'Value is not valid, you can use non-negative integers, including scientific notation'; /** * Returns the closest version to what the user provided that we have in our PromFlavorVersions for the currently selected flavor * Bugs: It will only reject versions that are a major release apart, so Mimir 2.x might get selected for Prometheus 2.8 if the user selects an incorrect flavor @@ -169,6 +174,14 @@ export const PromSettings = (props: Props) => { incrementalQueryOverlapWindow: '', }); + type ValidCount = { + codeModeMetricNamesSuggestionLimit: string; + }; + + const [validCount, updateValidCount] = useState({ + codeModeMetricNamesSuggestionLimit: '', + }); + return ( <> @@ -405,6 +418,49 @@ export const PromSettings = (props: Props) => {
+ {config.featureToggles.prometheusCodeModeMetricNamesSearch && ( +
+
+ + The maximum number of metric names that may appear as autocomplete suggestions in the query + editor's Code mode. + + } + interactive={true} + disabled={options.readOnly} + > + <> + + updateValidCount({ + ...validCount, + codeModeMetricNamesSuggestionLimit: e.currentTarget.value, + }) + } + data-testid={ + selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit + } + /> + {validateInput( + validCount.codeModeMetricNamesSuggestionLimit, + NON_NEGATIVE_INTEGER_REGEX, + countError + )} + + +
+
+ )} +
; + metricNamesAutocompleteSuggestionLimit: number; constructor( instanceSettings: DataSourceInstanceSettings, @@ -127,6 +128,8 @@ export class PrometheusDatasource this.variables = new PrometheusVariableSupport(this, this.templateSrv); this.exemplarsAvailable = true; this.cacheLevel = instanceSettings.jsonData.cacheLevel ?? PrometheusCacheLevel.Low; + this.metricNamesAutocompleteSuggestionLimit = + instanceSettings.jsonData.codeModeMetricNamesSuggestionLimit ?? SUGGESTIONS_LIMIT; this.cache = new QueryCache({ getTargetSignature: this.getPrometheusTargetSignature.bind(this), diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.test.tsx new file mode 100644 index 00000000000..b34a6d07038 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.test.tsx @@ -0,0 +1,161 @@ +import { render, screen, fireEvent, createEvent } from '@testing-library/react'; +import { cloneDeep, defaultsDeep } from 'lodash'; +import React from 'react'; + +import { PluginMeta, PluginType } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; + +import { CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT } from '../../components/monaco-query-field/monaco-completion-provider/data_provider'; +import { PromQueryEditorProps } from '../../components/types'; +import { PrometheusDatasource } from '../../datasource'; +import PromQlLanguageProvider from '../../language_provider'; +import { EmptyLanguageProviderMock } from '../../language_provider.mock'; +import { PromQuery } from '../../types'; +import { QueryEditorMode } from '../shared/types'; + +import { PromQueryEditorSelector } from './PromQueryEditorSelector'; + +beforeEach(() => { + jest.replaceProperty(config, 'featureToggles', { + prometheusCodeModeMetricNamesSearch: true, + }); +}); + +// We need to mock this because it seems jest has problem importing monaco in tests +jest.mock('../../components/monaco-query-field/MonacoQueryFieldWrapper', () => { + return { + MonacoQueryFieldWrapper: () => { + return 'MonacoQueryFieldWrapper'; + }, + }; +}); + +jest.mock('app/core/store', () => { + return { + get() { + return undefined; + }, + set() {}, + getObject(key: string, defaultValue: unknown) { + return defaultValue; + }, + }; +}); + +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + reportInteraction: jest.fn(), + }; +}); + +const defaultQuery = { + refId: 'A', + expr: 'metric{label1="foo", label2="bar"}', +}; + +const defaultMeta: PluginMeta = { + id: '', + name: '', + type: PluginType.datasource, + info: { + author: { + name: 'tester', + }, + description: 'testing', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + module: '', + baseUrl: '', +}; + +const getDefaultDatasource = (jsonDataOverrides = {}) => + new PrometheusDatasource( + { + id: 1, + uid: 'myDataSourceUid', + type: 'prometheus', + name: 'prom-test', + access: 'proxy', + url: '', + jsonData: jsonDataOverrides, + meta: defaultMeta, + readOnly: false, + }, + undefined, + new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider + ); + +const defaultProps = { + datasource: getDefaultDatasource(), + query: defaultQuery, + onRunQuery: () => {}, + onChange: () => {}, +}; + +const autocompleteInfoSelector = selectors.components.DataSource.Prometheus.queryEditor.code.metricsCountInfo; + +describe('PromQueryEditorSelector', () => { + it('does not show autocomplete info when the code editor first displays', async () => { + const { queryByTestId } = renderWithCodeMode(); + expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument(); + expect(queryByTestId(autocompleteInfoSelector)).not.toBeInTheDocument(); + }); + + it('shows autocomplete info when the expected event fires', async () => { + const { findByTestId } = renderWithCodeMode(); + fireEvent( + window, + createEvent( + CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, + window, + { + detail: { limit: 100, datasourceUid: 'myDataSourceUid' }, + }, + { EventType: 'CustomEvent' } + ) + ); + expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument(); + const autocompleteInfo = await findByTestId(autocompleteInfoSelector); + expect(autocompleteInfo).toBeInTheDocument(); + }); + + it('does not show autocomplete info when the triggering event refers to a different data source', async () => { + const { queryByTestId } = renderWithCodeMode(); + fireEvent( + window, + createEvent( + CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, + window, + { + detail: { limit: 100, datasourceUid: 'theWrongUid' }, + }, + { EventType: 'CustomEvent' } + ) + ); + expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument(); + const autocompleteInfo = await queryByTestId(autocompleteInfoSelector); + expect(autocompleteInfo).not.toBeInTheDocument(); + }); +}); + +function renderWithCodeMode() { + return renderWithProps({ editorMode: QueryEditorMode.Code, expr: 'my_metric' }); +} + +function renderWithProps(overrides?: Partial, componentProps: Partial = {}) { + const query = defaultsDeep(overrides ?? {}, cloneDeep(defaultQuery)); + const onChange = jest.fn(); + + const allProps = { ...defaultProps, ...componentProps }; + const stuff = render(); + return { onChange, ...stuff }; +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.tsx new file mode 100644 index 00000000000..d64c77c946e --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditorAutocompleteInfo.tsx @@ -0,0 +1,69 @@ +import React, { useState, useEffect, useCallback } from 'react'; + +import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; +import { IconButton, Text, Stack } from '@grafana/ui'; + +import { + CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, + isSuggestionsIncompleteEvent, +} from '../../components/monaco-query-field/monaco-completion-provider/data_provider'; +import { PromQueryEditorProps } from '../../components/types'; +import { QueryEditorMode } from '../shared/types'; + +interface Props { + datasourceUid: PromQueryEditorProps['datasource']['uid']; + editorMode: QueryEditorMode; +} + +export function PromQueryCodeEditorAutocompleteInfo(props: Readonly) { + const [autocompleteLimit, setAutocompleteLimit] = useState('n'); + const [autocompleteLimitExceeded, setAutocompleteLimitExceeded] = useState(false); + const handleSuggestionsIncompleteEvent = useCallback( + (e: Event) => { + if (!isSuggestionsIncompleteEvent(e)) { + return; + } + + if (e.detail.datasourceUid === props.datasourceUid) { + setAutocompleteLimitExceeded(true); + setAutocompleteLimit(e.detail.limit.toString()); + } + }, + [props.datasourceUid] + ); + + useEffect(() => { + addEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent); + + return () => { + removeEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent); + }; + }, [handleSuggestionsIncompleteEvent]); + + const showCodeModeAutocompleteDisclaimer = (): boolean => { + return ( + Boolean(config.featureToggles.prometheusCodeModeMetricNamesSearch) && + props.editorMode === QueryEditorMode.Code && + autocompleteLimitExceeded + ); + }; + + if (!showCodeModeAutocompleteDisclaimer()) { + return null; + } + + return ( +
+ + + Autocomplete suggestions limited + + + +
+ ); +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx index f85090e0cad..b46ef45c6f0 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryEditorSelector.tsx @@ -21,6 +21,7 @@ import { changeEditorMode, getQueryWithDefaults } from '../state'; import { PromQueryBuilderContainer } from './PromQueryBuilderContainer'; import { PromQueryBuilderOptions } from './PromQueryBuilderOptions'; import { PromQueryCodeEditor } from './PromQueryCodeEditor'; +import { PromQueryCodeEditorAutocompleteInfo } from './PromQueryCodeEditorAutocompleteInfo'; export const FORMAT_OPTIONS: Array> = [ { label: 'Time series', value: 'time_series' }, @@ -138,6 +139,7 @@ export const PromQueryEditorSelector = React.memo((props) => { Run queries )} +
diff --git a/public/app/plugins/datasource/prometheus/types.ts b/public/app/plugins/datasource/prometheus/types.ts index f3c5730b380..f6837388e05 100644 --- a/public/app/plugins/datasource/prometheus/types.ts +++ b/public/app/plugins/datasource/prometheus/types.ts @@ -51,6 +51,7 @@ export interface PromOptions extends DataSourceJsonData { disableRecordingRules?: boolean; sigV4Auth?: boolean; oauthPassThru?: boolean; + codeModeMetricNamesSuggestionLimit?: number; } export type ExemplarTraceIdDestination = {