diff --git a/.betterer.results b/.betterer.results index 6429e7b67b5..1ab5b19047e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -420,9 +420,6 @@ exports[`better eslint`] = { "packages/grafana-o11y-ds-frontend/src/createNodeGraphFrames.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "packages/grafana-prometheus/src/components/PromQueryField.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "packages/grafana-prometheus/src/components/metrics-browser/useMetricsLabelsValues.ts:5381": [ [0, 0, 0, "Direct usage of localStorage is not allowed. import store from @grafana/data instead", "0"], [0, 0, 0, "Direct usage of localStorage is not allowed. import store from @grafana/data instead", "1"], diff --git a/packages/grafana-prometheus/src/components/PromQueryField.tsx b/packages/grafana-prometheus/src/components/PromQueryField.tsx index 93094992348..289b206b7d2 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.tsx @@ -1,10 +1,17 @@ // Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx import { css, cx } from '@emotion/css'; -import { MutableRefObject, ReactNode, useCallback, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useState } from 'react'; -import { getDefaultTimeRange, isDataFrame, QueryEditorProps, QueryHint, toLegacyResponseData } from '@grafana/data'; +import { + DataFrame, + getDefaultTimeRange, + isDataFrame, + QueryEditorProps, + QueryHint, + toLegacyResponseData, +} from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { t } from '@grafana/i18n'; +import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { clearButtonStyles, Icon, useTheme2 } from '@grafana/ui'; @@ -12,12 +19,9 @@ import { PrometheusDatasource } from '../datasource'; import { getInitHints } from '../query_hints'; import { PromOptions, PromQuery } from '../types'; -import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise'; import { MetricsBrowser } from './metrics-browser/MetricsBrowser'; import { MetricsBrowserProvider } from './metrics-browser/MetricsBrowserContext'; import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper'; -import { useMetricsState } from './useMetricsState'; -import { usePromQueryFieldEffects } from './usePromQueryFieldEffects'; interface PromQueryFieldProps extends QueryEditorProps { ExtraFieldElement?: ReactNode; @@ -40,68 +44,32 @@ export const PromQueryField = (props: PromQueryFieldProps) => { const theme = useTheme2(); - const [syntaxLoaded, setSyntaxLoaded] = useState(false); const [hint, setHint] = useState(null); const [labelBrowserVisible, setLabelBrowserVisible] = useState(false); - const updateLanguage = useCallback(() => { - if (languageProvider.retrieveMetrics()) { - setSyntaxLoaded(true); - } - }, [languageProvider]); + const refreshHint = useCallback( + (series: DataFrame[]) => { + const initHints = getInitHints(datasource); + const initHint = initHints[0] ?? null; - const refreshMetrics = useCallback( - async (languageProviderInitRef: MutableRefObject | null>) => { - // Cancel any existing initialization using the ref - if (languageProviderInitRef.current) { - languageProviderInitRef.current.cancel(); - } - - if (!languageProvider || !range) { + // If no data or empty series, use default hint + if (!data?.series?.length) { + setHint(initHint); return; } - try { - const initialization = makePromiseCancelable(languageProvider.start(range)); - languageProviderInitRef.current = initialization; + const result = isDataFrame(series[0]) ? series.map(toLegacyResponseData) : series; + const queryHints = datasource.getQueryHints(query, result); + let queryHint = queryHints.length > 0 ? queryHints[0] : null; - const remainingTasks = await initialization.promise; - - // If there are remaining tasks, wait for them - if (Array.isArray(remainingTasks) && remainingTasks.length > 0) { - await Promise.all(remainingTasks); - } - - updateLanguage(); - } catch (err) { - if (isCancelablePromiseRejection(err) && err.isCanceled) { - // do nothing, promise was canceled - } else { - throw err; - } - } finally { - languageProviderInitRef.current = null; - } + setHint(queryHint ?? initHint); }, - [languageProvider, range, updateLanguage] + [data, datasource, query] ); - const refreshHint = useCallback(() => { - const initHints = getInitHints(datasource); - const initHint = initHints[0] ?? null; - - // If no data or empty series, use default hint - if (!data?.series?.length) { - setHint(initHint); - return; - } - - const result = isDataFrame(data.series[0]) ? data.series.map(toLegacyResponseData) : data.series; - const queryHints = datasource.getQueryHints(query, result); - let queryHint = queryHints.length > 0 ? queryHints[0] : null; - - setHint(queryHint ?? initHint); - }, [data, datasource, query]); + useEffect(() => { + refreshHint(data?.series ?? []); + }, [data?.series, refreshHint]); const onChangeQuery = (value: string, override?: boolean) => { if (!onChange) { @@ -137,11 +105,6 @@ export const PromQueryField = (props: PromQueryFieldProps) => { onRunQuery(); }; - // Use our custom effects hook - usePromQueryFieldEffects(languageProvider, range, data?.series, refreshMetrics, refreshHint); - - const { chooserText, buttonDisabled } = useMetricsState(datasource, languageProvider, syntaxLoaded); - return ( <>
{ 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 0200645d392..e37f9b47f57 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx +++ b/packages/grafana-prometheus/src/components/monaco-query-field/MonacoQueryField.tsx @@ -155,7 +155,13 @@ const MonacoQueryField = (props: Props) => { historyProvider: historyRef.current, languageProvider: lpRef.current, }); - const completionProvider = getCompletionProvider(monaco, dataProvider, timeRange); + + // Create completion provider with state for Ctrl+Space detection + const { provider: completionProvider, state: completionState } = getCompletionProvider( + monaco, + dataProvider, + timeRange + ); // completion-providers in monaco are not registered directly to editor-instances, // they are registered to languages. this makes it hard for us to have @@ -182,7 +188,31 @@ const MonacoQueryField = (props: Props) => { filteringCompletionProvider ); - autocompleteDisposeFun.current = dispose; + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.code === 'Space') { + // Only handle if this editor is focused + if (editor.hasTextFocus()) { + event.preventDefault(); + event.stopPropagation(); + + completionState.isManualTriggerRequested = true; + editor.trigger('keyboard', 'editor.action.triggerSuggest', {}); + setTimeout(() => { + completionState.isManualTriggerRequested = false; + }, 300); + } + } + }; + + // Add global listener + document.addEventListener('keydown', handleKeyDown, true); + + // Combine cleanup functions + autocompleteDisposeFun.current = () => { + document.removeEventListener('keydown', handleKeyDown, true); + dispose(); + }; + // this code makes the editor resize itself so that the content fits // (it will grow taller when necessary) // FIXME: maybe move this functionality into CodeEditor, like: 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 index e6df676beba..7219a53505a 100644 --- 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 @@ -1,6 +1,6 @@ import { config } from '@grafana/runtime'; -import { SUGGESTIONS_LIMIT } from '../../../constants'; +import { DEFAULT_COMPLETION_LIMIT } from '../../../constants'; import { getFunctions } from '../../../promql'; import { getMockTimeRange } from '../../../test/mocks/datasource'; @@ -12,7 +12,7 @@ const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', ' const dataProviderSettings = { languageProvider: { datasource: { - metricNamesAutocompleteSuggestionLimit: SUGGESTIONS_LIMIT, + metricNamesAutocompleteSuggestionLimit: DEFAULT_COMPLETION_LIMIT, }, queryLabelKeys: jest.fn(), queryLabelValues: jest.fn(), @@ -23,9 +23,9 @@ const dataProviderSettings = { } as unknown as DataProviderParams; let dataProvider = new DataProvider(dataProviderSettings); const metrics = { - beyondLimit: Array.from(Array(SUGGESTIONS_LIMIT + 1), (_, i) => `metric_name_${i}`), + beyondLimit: Array.from(Array(DEFAULT_COMPLETION_LIMIT + 1), (_, i) => `metric_name_${i}`), get atLimit() { - return this.beyondLimit.slice(0, SUGGESTIONS_LIMIT - 1); + return this.beyondLimit.slice(0, DEFAULT_COMPLETION_LIMIT - 1); }, }; @@ -171,7 +171,7 @@ type MetricNameSituation = Extract { - jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.atLimit); + jest.spyOn(dataProvider, 'queryMetricNames').mockResolvedValue(metrics.atLimit); const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.atLimit.length); const situation: Situation = { type: situationType, @@ -232,7 +232,7 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(false); // Cross the metric names threshold, without text input - jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit); + jest.spyOn(dataProvider, 'queryMetricNames').mockResolvedValue(metrics.beyondLimit); dataProvider.monacoSettings.setInputInRange(''); await getCompletions(situation, dataProvider, timeRange); expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true); @@ -250,7 +250,7 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat }; const testMetrics = ['metric_name_1', 'metric_name_2', 'metric_name_1_with_extra_terms', 'unrelated_metric']; - jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(testMetrics); + jest.spyOn(dataProvider, 'queryMetricNames').mockResolvedValue(testMetrics); // Test with a complex query (> 4 terms) dataProvider.monacoSettings.setInputInRange('metric name 1 with extra terms more'); @@ -284,7 +284,7 @@ describe('Label value completions', () => { getAllMetricNames: jest.fn(), metricNamesToMetrics: jest.fn(), getHistory: jest.fn(), - getLabelValues: jest.fn().mockResolvedValue(['value1', 'value"2', 'value\\3', "value'4"]), + queryLabelValues: jest.fn().mockResolvedValue(['value1', 'value"2', 'value\\3', "value'4"]), monacoSettings: { setInputInRange: jest.fn(), inputInRange: '', @@ -397,7 +397,7 @@ describe('Label value completions', () => { const timeRange = getMockTimeRange(); it('should handle empty values', async () => { - jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue(['']); + jest.spyOn(dataProvider, 'queryLabelValues').mockResolvedValue(['']); const situation: Situation = { type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', @@ -412,7 +412,7 @@ describe('Label value completions', () => { }); it('should handle values with multiple special characters', async () => { - jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue(['test"\\value']); + jest.spyOn(dataProvider, 'queryLabelValues').mockResolvedValue(['test"\\value']); const situation: Situation = { type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', @@ -427,7 +427,7 @@ describe('Label value completions', () => { }); it('should handle non-string values', async () => { - jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue([123 as unknown as string]); + jest.spyOn(dataProvider, 'queryLabelValues').mockResolvedValue([123 as unknown as string]); const situation: Situation = { type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', 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 f7fc08f8bce..2a463548bfe 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 @@ -5,11 +5,13 @@ import { languages } from 'monaco-editor'; import { TimeRange } from '@grafana/data'; import { config } from '@grafana/runtime'; +import { DEFAULT_COMPLETION_LIMIT } from '../../../constants'; import { escapeLabelValueInExactSelector, prometheusRegularEscape } from '../../../escaping'; import { getFunctions } from '../../../promql'; import { isValidLegacyName } from '../../../utf8_support'; import { DataProvider } from './data_provider'; +import { TriggerType } from './monaco-completion-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 @@ -63,8 +65,12 @@ export function filterMetricNames({ metricNames, inputText, limit }: MetricFilte } // we order items like: history, functions, metrics -function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] { - let metricNames = dataProvider.getAllMetricNames(); +async function getAllMetricNamesCompletions( + searchTerm: string | undefined, + dataProvider: DataProvider, + timeRange: TimeRange +): Promise { + let metricNames = await dataProvider.queryMetricNames(timeRange, searchTerm); if ( config.featureToggles.prometheusCodeModeMetricNamesSearch && @@ -110,9 +116,16 @@ const getFunctionCompletions: () => Completion[] = () => { })); }; -async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise { - const metricNames = getAllMetricNamesCompletions(dataProvider); +async function getFunctionsOnlyCompletions(): Promise { + return Promise.resolve(getFunctionCompletions()); +} +async function getAllFunctionsAndMetricNamesCompletions( + searchTerm: string | undefined, + dataProvider: DataProvider, + timeRange: TimeRange +): Promise { + const metricNames = await getAllMetricNamesCompletions(searchTerm, dataProvider, timeRange); return [...getFunctionCompletions(), ...metricNames]; } @@ -144,7 +157,11 @@ function getAllHistoryCompletions(dataProvider: DataProvider): Completion[] { })); } -function makeSelector(metricName: string | undefined, labels: Label[]): string { +function makeSelector(metricName: string | undefined, labels: Label[]): string | undefined { + if (metricName === undefined && labels.length === 0) { + return undefined; + } + const allLabels = [...labels]; // we transform the metricName to a label, if it exists @@ -165,19 +182,13 @@ async function getLabelNames( dataProvider: DataProvider, timeRange: TimeRange ): Promise { - if (metric === undefined && otherLabels.length === 0) { - // if there is no filtering, we have to use a special endpoint - return Promise.resolve(dataProvider.getAllLabelNames()); - } else { - const selector = makeSelector(metric, otherLabels); - const labelNames = await dataProvider.getSeriesLabels(timeRange, selector); - - // Exclude __name__ from output - otherLabels.push({ name: '__name__', value: '', op: '!=' }); - const usedLabelNames = new Set(otherLabels.map((l) => l.name)); - // names used in the query - return labelNames.filter((l) => !usedLabelNames.has(l)); - } + const selector = makeSelector(metric, otherLabels); + const labelNames = await dataProvider.queryLabelKeys(timeRange, selector, DEFAULT_COMPLETION_LIMIT); + // Exclude __name__ from output + otherLabels.push({ name: '__name__', value: '', op: '!=' }); + const usedLabelNames = new Set(otherLabels.map((l) => l.name)); + // names used in the query + return labelNames.filter((l) => !usedLabelNames.has(l)); } async function getLabelNamesForCompletions( @@ -232,13 +243,8 @@ async function getLabelValues( dataProvider: DataProvider, timeRange: TimeRange ): Promise { - if (metric === undefined && otherLabels.length === 0) { - // if there is no filtering, we have to use a special endpoint - return dataProvider.getLabelValues(timeRange, labelName); - } else { - const selector = makeSelector(metric, otherLabels); - return await dataProvider.getSeriesValues(timeRange, labelName, selector); - } + const selector = makeSelector(metric, otherLabels); + return await dataProvider.queryLabelValues(timeRange, labelName, selector); } async function getLabelValuesForMetricCompletions( @@ -262,21 +268,30 @@ function formatLabelValueForCompletion(value: string, betweenQuotes: boolean): s return betweenQuotes ? text : `"${text}"`; } -export function getCompletions( +export async function getCompletions( situation: Situation, dataProvider: DataProvider, - timeRange: TimeRange + timeRange: TimeRange, + searchTerm?: string, + triggerType: TriggerType = 'full' ): Promise { switch (situation.type) { case 'IN_DURATION': return Promise.resolve(DURATION_COMPLETIONS); case 'IN_FUNCTION': - return getAllFunctionsAndMetricNamesCompletions(dataProvider); + return triggerType === 'full' + ? getAllFunctionsAndMetricNamesCompletions(searchTerm, dataProvider, timeRange) + : getFunctionsOnlyCompletions(); case 'AT_ROOT': { - return getAllFunctionsAndMetricNamesCompletions(dataProvider); + return triggerType === 'full' + ? getAllFunctionsAndMetricNamesCompletions(searchTerm, dataProvider, timeRange) + : getFunctionsOnlyCompletions(); } case 'EMPTY': { - const metricNames = getAllMetricNamesCompletions(dataProvider); + if (triggerType === 'partial') { + return Promise.resolve(getFunctionCompletions()); + } + const metricNames = await getAllMetricNamesCompletions(searchTerm, dataProvider, timeRange); const historyCompletions = getAllHistoryCompletions(dataProvider); return Promise.resolve([...historyCompletions, ...getFunctionCompletions(), ...metricNames]); } 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 index b5772fa45cd..8be8851f75a 100644 --- 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 @@ -1,9 +1,10 @@ -import { HistoryItem } from '@grafana/data'; -import type { Monaco } from '@grafana/ui'; // used in TSDoc `@link` below +import { HistoryItem, TimeRange } from '@grafana/data'; +import { DEFAULT_COMPLETION_LIMIT, METRIC_LABEL } from '../../../constants'; import { type PrometheusLanguageProviderInterface } from '../../../language_provider'; +import { removeQuotesIfExist } from '../../../language_utils'; import { PromQuery } from '../../../types'; -import { isValidLegacyName } from '../../../utf8_support'; +import { escapeForUtf8Support, isValidLegacyName } from '../../../utf8_support'; export const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete'; @@ -38,11 +39,10 @@ export interface DataProviderParams { export class DataProvider { readonly languageProvider: PrometheusLanguageProviderInterface; readonly historyProvider: Array>; - readonly getSeriesLabels: typeof this.languageProvider.queryLabelKeys; - readonly getSeriesValues: typeof this.languageProvider.queryLabelValues; - readonly getAllLabelNames: typeof this.languageProvider.retrieveLabelKeys; - readonly getLabelValues: typeof this.languageProvider.queryLabelValues; - readonly metricNamesSuggestionLimit: number; + + readonly metricNamesSuggestionLimit: number = DEFAULT_COMPLETION_LIMIT; + readonly queryLabelKeys: typeof this.languageProvider.queryLabelKeys; + readonly queryLabelValues: typeof this.languageProvider.queryLabelValues; /** * The text that's been typed so far within the current {@link Monaco.Range | Range}. * @@ -56,14 +56,38 @@ export class DataProvider { this.languageProvider = params.languageProvider; this.historyProvider = params.historyProvider; this.inputInRange = ''; - this.metricNamesSuggestionLimit = this.languageProvider.datasource.metricNamesAutocompleteSuggestionLimit; this.suggestionsIncomplete = false; - this.getSeriesLabels = this.languageProvider.queryLabelKeys.bind(this.languageProvider); - this.getSeriesValues = this.languageProvider.queryLabelValues.bind(this.languageProvider); - this.getAllLabelNames = this.languageProvider.retrieveLabelKeys.bind(this.languageProvider); - this.getLabelValues = this.languageProvider.queryLabelValues.bind(this.languageProvider); + + this.queryLabelKeys = this.languageProvider.queryLabelKeys.bind(this.languageProvider); + this.queryLabelValues = this.languageProvider.queryLabelValues.bind(this.languageProvider); } + /** + * Queries metric names with optional filtering. + * Safely constructs regex patterns and handles errors. + */ + queryMetricNames = async (timeRange: TimeRange, searchTerm: string | undefined): Promise => { + try { + let match: string | undefined; + if (searchTerm) { + const escapedWord = escapeForUtf8Support(removeQuotesIfExist(searchTerm)); + match = `{__name__=~".*${escapedWord}.*"}`; + } + + const result = await this.languageProvider.queryLabelValues( + timeRange, + METRIC_LABEL, + match, + DEFAULT_COMPLETION_LIMIT + ); + + return Array.isArray(result) ? result : []; + } catch (error) { + console.warn('Failed to query metric names:', error); + return []; + } + }; + getHistory(): string[] { return this.historyProvider.map((h) => h.query.expr).filter(Boolean); } diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.ts index def57a02621..576985fae86 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/monaco-completion-provider.ts @@ -7,6 +7,8 @@ import { DataProvider } from './data_provider'; import { getSituation } from './situation'; import { NeverCaseError } from './util'; +export type TriggerType = 'partial' | 'full'; + export function getSuggestOptions(): monacoTypes.editor.ISuggestOptions { return { // monaco-editor sometimes provides suggestions automatically, i am not @@ -47,14 +49,57 @@ function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): mona } } +function getTriggerType( + context: monacoTypes.languages.CompletionContext, + word: monacoTypes.editor.IWordAtPosition | null, + model: monacoTypes.editor.ITextModel, + position: monacoTypes.Position, + isManualTrigger: boolean +): TriggerType { + // Manual trigger (Ctrl+Space) + if (isManualTrigger) { + return 'full'; + } + + // Trigger characters + const triggerChars = ['{', ',', '[', '(', '=', '~', ' ', '"']; + const charBeforeCursor = model.getValueInRange({ + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: Math.max(1, position.column - 1), + endColumn: position.column, + }); + + if (triggerChars.includes(charBeforeCursor)) { + return 'full'; + } + + // Word length >= 3 + if (word && word.word.length >= 3) { + return 'full'; + } + + return 'partial'; +} + export function getCompletionProvider( monaco: Monaco, dataProvider: DataProvider, timeRange: TimeRange -): monacoTypes.languages.CompletionItemProvider { +): { provider: monacoTypes.languages.CompletionItemProvider; state: { isManualTriggerRequested: boolean } } { + // Short debounce to catch rapid typing + let debounceTimer: ReturnType | null = null; + const DEBOUNCE_DELAY = 150; // Much shorter delay to catch rapid typing + + // Simple local state + const state = { + isManualTriggerRequested: false, + }; + const provideCompletionItems = ( model: monacoTypes.editor.ITextModel, - position: monacoTypes.Position + position: monacoTypes.Position, + context: monacoTypes.languages.CompletionContext ): monacoTypes.languages.ProviderResult => { const word = model.getWordAtPosition(position); const range = @@ -66,13 +111,67 @@ export function getCompletionProvider( endColumn: word.endColumn, }) : monaco.Range.fromPositions(position); + + const isManualTrigger = state.isManualTriggerRequested; + if (isManualTrigger) { + state.isManualTriggerRequested = false; + } + + const triggerType: TriggerType = getTriggerType(context, word, model, position, isManualTrigger); + + // For immediate triggers (manual, trigger chars, or already 3+ chars), execute immediately + const isImmediate = isManualTrigger || triggerType === 'full'; + + if (isImmediate) { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + return executeCompletionLogic(model, position, range, dataProvider, timeRange, word?.word, triggerType); + } + + // For typing scenarios, use short debounce to catch rapid typing + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + return new Promise((resolve) => { + debounceTimer = setTimeout(() => { + // Re-check if we should use full completions after debounce + const updatedWord = model.getWordAtPosition(position); + const updatedTriggerType: TriggerType = getTriggerType(context, updatedWord, model, position, false) + ? 'full' + : 'partial'; + + executeCompletionLogic( + model, + position, + range, + dataProvider, + timeRange, + updatedWord?.word, + updatedTriggerType + ).then(resolve); + }, DEBOUNCE_DELAY); + }); + }; + + const executeCompletionLogic = async ( + model: monacoTypes.editor.ITextModel, + position: monacoTypes.Position, + range: monacoTypes.Range, + dataProvider: DataProvider, + timeRange: TimeRange, + wordText?: string, + triggerType: TriggerType = 'full' + ): Promise => { // documentation says `position` will be "adjusted" in `getOffsetAt` // i don't know what that means, to be sure i clone it - const positionClone = { column: position.column, lineNumber: position.lineNumber, }; + dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range)); // Check to see if the browser supports window.getSelection() @@ -87,7 +186,9 @@ export function getCompletionProvider( const offset = model.getOffsetAt(positionClone); const situation = getSituation(model.getValue(), offset); const completionsPromise = - situation != null ? getCompletions(situation, dataProvider, timeRange) : Promise.resolve([]); + situation != null + ? getCompletions(situation, dataProvider, timeRange, wordText, triggerType) + : Promise.resolve([]); return completionsPromise.then((items) => { // monaco by-default alphabetically orders the items. @@ -95,27 +196,29 @@ export function getCompletionProvider( // so that monaco keeps the order we use const maxIndexDigits = items.length.toString().length; const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => ({ - kind: getMonacoCompletionItemKind(item.type, monaco), - label: item.label, - insertText: item.insertText, - insertTextRules: item.insertTextRules, - detail: item.detail, - documentation: item.documentation, - sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have range, - command: item.triggerOnInsert - ? { - id: 'editor.action.triggerSuggest', - title: '', - } - : undefined, + label: item.label, + detail: item.detail, + insertText: item.insertText, + documentation: item.documentation, + insertTextRules: item.insertTextRules, + kind: getMonacoCompletionItemKind(item.type, monaco), + sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have + command: item.triggerOnInsert ? { id: 'editor.action.triggerSuggest', title: '' } : undefined, })); - return { suggestions, incomplete: dataProvider.monacoSettings.suggestionsIncomplete }; + + return { + suggestions, + incomplete: dataProvider.monacoSettings.suggestionsIncomplete, + }; }); }; return { - triggerCharacters: ['{', ',', '[', '(', '=', '~', ' ', '"'], - provideCompletionItems, + provider: { + triggerCharacters: ['{', ',', '[', '(', '=', '~', ' ', '"'], + provideCompletionItems, + }, + state, }; } diff --git a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts index 7b1bbac866a..dfc0f40a556 100644 --- a/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts +++ b/packages/grafana-prometheus/src/components/monaco-query-field/monaco-completion-provider/situation.ts @@ -186,6 +186,11 @@ const RESOLVERS: Resolver[] = [ path: [PromQL], fun: resolveTopLevel, }, + { + // Partially written metric name + path: [Identifier, VectorSelector, PromQL], + fun: resolveTopLevel, + }, { path: [FunctionCallBody], fun: resolveInFunction, diff --git a/packages/grafana-prometheus/src/components/useMetricsState.test.ts b/packages/grafana-prometheus/src/components/useMetricsState.test.ts deleted file mode 100644 index c632935a287..00000000000 --- a/packages/grafana-prometheus/src/components/useMetricsState.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { renderHook } from '@testing-library/react'; - -import { PrometheusDatasource } from '../datasource'; -import { PrometheusLanguageProviderInterface } from '../language_provider'; - -import { useMetricsState } from './useMetricsState'; - -// Mock implementations -const createMockLanguageProvider = (metrics: string[] = []): PrometheusLanguageProviderInterface => - ({ - retrieveMetrics: () => metrics, - }) as unknown as PrometheusLanguageProviderInterface; - -const createMockDatasource = (lookupsDisabled = false): PrometheusDatasource => - ({ - lookupsDisabled, - }) as unknown as PrometheusDatasource; - -describe('useMetricsState', () => { - describe('chooserText', () => { - it('should return disabled message when lookups are disabled', () => { - const datasource = createMockDatasource(true); - const languageProvider = createMockLanguageProvider([]); - const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true)); - expect(result.current.chooserText).toBe('(Disabled)'); - }); - - it('should return loading message when syntax is not loaded', () => { - const datasource = createMockDatasource(); - const languageProvider = createMockLanguageProvider(['metric1']); - const { result } = renderHook(() => useMetricsState(datasource, languageProvider, false)); - expect(result.current.chooserText).toBe('Loading metrics...'); - }); - - it('should return no metrics message when no metrics are found', () => { - const datasource = createMockDatasource(); - const languageProvider = createMockLanguageProvider([]); - const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true)); - expect(result.current.chooserText).toBe('(No metrics found)'); - }); - - it('should return metrics browser text when metrics are available', () => { - const datasource = createMockDatasource(); - const languageProvider = createMockLanguageProvider(['metric1']); - const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true)); - expect(result.current.chooserText).toBe('Metrics browser'); - }); - }); - - describe('buttonDisabled', () => { - it('should be disabled when syntax is not loaded', () => { - const datasource = createMockDatasource(); - const languageProvider = createMockLanguageProvider(['metric1']); - const { result } = renderHook(() => useMetricsState(datasource, languageProvider, false)); - expect(result.current.buttonDisabled).toBe(true); - }); - - it('should be disabled when no metrics are available', () => { - const datasource = createMockDatasource(); - const languageProvider = createMockLanguageProvider([]); - const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true)); - expect(result.current.buttonDisabled).toBe(true); - }); - - it('should be enabled when syntax is loaded and metrics are available', () => { - const datasource = createMockDatasource(); - const languageProvider = createMockLanguageProvider(['metric1']); - const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true)); - expect(result.current.buttonDisabled).toBe(false); - }); - }); - - describe('hasMetrics', () => { - it('should be false when no metrics are available', () => { - const datasource = createMockDatasource(); - const languageProvider = createMockLanguageProvider([]); - const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true)); - expect(result.current.hasMetrics).toBe(false); - }); - - it('should be true when metrics are available', () => { - const datasource = createMockDatasource(); - const languageProvider = createMockLanguageProvider(['metric1']); - const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true)); - expect(result.current.hasMetrics).toBe(true); - }); - }); - - describe('memoization', () => { - it('should return same values when dependencies have not changed', () => { - const datasource = createMockDatasource(); - const languageProvider = createMockLanguageProvider(['metric1']); - const { result, rerender } = renderHook(() => useMetricsState(datasource, languageProvider, true)); - const firstResult = result.current; - - rerender(); - expect(result.current).toBe(firstResult); - }); - - it('should update when datasource lookupsDisabled changes', () => { - const initialDatasource = createMockDatasource(false); - const languageProvider = createMockLanguageProvider(['metric1']); - const { result, rerender } = renderHook(({ ds }) => useMetricsState(ds, languageProvider, true), { - initialProps: { ds: initialDatasource }, - }); - const firstResult = result.current; - - const updatedDatasource = createMockDatasource(true); - rerender({ ds: updatedDatasource }); - expect(result.current).not.toBe(firstResult); - }); - }); -}); diff --git a/packages/grafana-prometheus/src/components/useMetricsState.ts b/packages/grafana-prometheus/src/components/useMetricsState.ts deleted file mode 100644 index 3ae3fb9aa6f..00000000000 --- a/packages/grafana-prometheus/src/components/useMetricsState.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useMemo } from 'react'; - -import { PrometheusDatasource } from '../datasource'; -import { PrometheusLanguageProviderInterface } from '../language_provider'; - -function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) { - if (metricsLookupDisabled) { - return '(Disabled)'; - } - - if (!hasSyntax) { - return 'Loading metrics...'; - } - - if (!hasMetrics) { - return '(No metrics found)'; - } - - return 'Metrics browser'; -} - -export function useMetricsState( - datasource: PrometheusDatasource, - languageProvider: PrometheusLanguageProviderInterface, - syntaxLoaded: boolean -) { - return useMemo(() => { - const hasMetrics = languageProvider.retrieveMetrics().length > 0; - const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics); - const buttonDisabled = !(syntaxLoaded && hasMetrics); - - return { - hasMetrics, - chooserText, - buttonDisabled, - }; - }, [languageProvider, datasource.lookupsDisabled, syntaxLoaded]); -} diff --git a/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts b/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts deleted file mode 100644 index d3e5017bba1..00000000000 --- a/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { renderHook } from '@testing-library/react'; - -import { DataFrame, dateTime, TimeRange } from '@grafana/data'; - -import { PrometheusLanguageProviderInterface } from '../language_provider'; - -import { usePromQueryFieldEffects } from './usePromQueryFieldEffects'; - -type TestProps = { - languageProvider: PrometheusLanguageProviderInterface; - range: TimeRange | undefined; - series: DataFrame[]; -}; - -describe('usePromQueryFieldEffects', () => { - const mockLanguageProvider = { - start: jest.fn().mockResolvedValue([]), - timeRange: {}, - metrics: ['metric1'], - startTask: Promise.resolve(), - datasource: {}, - lookupsDisabled: false, - syntax: jest.fn(), - hasLookupsDisabled: jest.fn(), - getBeginningCompletionItems: jest.fn(), - getLabelCompletionItems: jest.fn(), - getMetricCompletionItems: jest.fn(), - getTermCompletionItems: jest.fn(), - request: jest.fn(), - importQueries: jest.fn(), - labelFetchTs: 0, - getDefaultCacheHeaders: jest.fn(), - modifyQuery: jest.fn(), - } as unknown as PrometheusLanguageProviderInterface; - - const mockRange: TimeRange = { - from: dateTime('2022-01-01T00:00:00Z'), - to: dateTime('2022-01-02T00:00:00Z'), - raw: { - from: 'now-1d', - to: 'now', - }, - }; - - const mockNewRange: TimeRange = { - from: dateTime('2022-01-02T00:00:00Z'), - to: dateTime('2022-01-03T00:00:00Z'), - raw: { - from: 'now-1d', - to: 'now', - }, - }; - - let refreshMetricsMock: jest.Mock; - let refreshHintMock: jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - refreshMetricsMock = jest.fn().mockImplementation(() => Promise.resolve()); - refreshHintMock = jest.fn(); - }); - - it('should call refreshMetrics and refreshHint on initial render', async () => { - renderHook(() => - usePromQueryFieldEffects(mockLanguageProvider, mockRange, [], refreshMetricsMock, refreshHintMock) - ); - - expect(refreshMetricsMock).toHaveBeenCalledTimes(1); - expect(refreshHintMock).toHaveBeenCalledTimes(2); - }); - - it('should call refreshMetrics when the time range changes', async () => { - const { rerender } = renderHook( - (props: TestProps) => - usePromQueryFieldEffects( - props.languageProvider, - props.range, - props.series, - refreshMetricsMock, - refreshHintMock - ), - { - initialProps: { - languageProvider: mockLanguageProvider, - range: mockRange, - series: [] as DataFrame[], - }, - } - ); - - // Initial render already called refreshMetrics once - expect(refreshMetricsMock).toHaveBeenCalledTimes(1); - - // Change the range - rerender({ - languageProvider: mockLanguageProvider, - range: mockNewRange, - series: [] as DataFrame[], - }); - - expect(refreshMetricsMock).toHaveBeenCalledTimes(2); - }); - - it('should not call refreshMetrics when the time range is the same', () => { - const { rerender } = renderHook( - (props: TestProps) => - usePromQueryFieldEffects( - props.languageProvider, - props.range, - props.series, - refreshMetricsMock, - refreshHintMock - ), - { - initialProps: { - languageProvider: mockLanguageProvider, - range: mockRange, - series: [] as DataFrame[], - }, - } - ); - - // Initial render already called refreshMetrics once - expect(refreshMetricsMock).toHaveBeenCalledTimes(1); - - // Rerender with the same range - rerender({ - languageProvider: mockLanguageProvider, - range: { ...mockRange }, // create a new object with the same values - series: [] as DataFrame[], - }); - - // Should still be called only once (from initial render) - expect(refreshMetricsMock).toHaveBeenCalledTimes(1); - }); - - it('should call refreshHint when series changes', () => { - const mockSeries = [{ name: 'new series', fields: [], length: 0 }] as DataFrame[]; - const { rerender } = renderHook( - (props: TestProps) => - usePromQueryFieldEffects( - props.languageProvider, - props.range, - props.series, - refreshMetricsMock, - refreshHintMock - ), - { - initialProps: { - languageProvider: mockLanguageProvider, - range: mockRange, - series: [] as DataFrame[], - }, - } - ); - - // Initial render already called refreshHint once - expect(refreshHintMock).toHaveBeenCalledTimes(2); - - refreshHintMock.mockClear(); - - // Change the series - rerender({ - languageProvider: mockLanguageProvider, - range: mockRange, - series: mockSeries, - }); - - expect(refreshHintMock).toHaveBeenCalledTimes(1); - }); - - it('should not call refreshHint when series is the same', () => { - const series = [] as DataFrame[]; - const { rerender } = renderHook( - (props: TestProps) => - usePromQueryFieldEffects( - props.languageProvider, - props.range, - props.series, - refreshMetricsMock, - refreshHintMock - ), - { - initialProps: { - languageProvider: mockLanguageProvider, - range: mockRange, - series, - }, - } - ); - - // Initial render already called refreshHint once - refreshHintMock.mockClear(); - - // Rerender with the same series - rerender({ - languageProvider: mockLanguageProvider, - range: mockRange, - series, // same empty array - }); - - expect(refreshHintMock).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts b/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts deleted file mode 100644 index b2371e57236..00000000000 --- a/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { MutableRefObject, useEffect, useRef } from 'react'; - -import { DataFrame, DateTime, TimeRange } from '@grafana/data'; - -import { PrometheusLanguageProviderInterface } from '../language_provider'; -import { roundMsToMin } from '../language_utils'; - -import { CancelablePromise } from './cancelable-promise'; - -export function usePromQueryFieldEffects( - languageProvider: PrometheusLanguageProviderInterface, - range: TimeRange | undefined, - series: DataFrame[] | undefined, - refreshMetrics: (languageProviderInitRef: MutableRefObject | null>) => Promise, - refreshHint: () => void -) { - const lastRangeRef = useRef<{ from: DateTime; to: DateTime } | null>(null); - const languageProviderInitRef = useRef | null>(null); - - // Effect for initial load - useEffect(() => { - if (languageProvider) { - refreshMetrics(languageProviderInitRef); - } - refreshHint(); - - return () => { - if (languageProviderInitRef.current) { - languageProviderInitRef.current.cancel(); - languageProviderInitRef.current = null; - } - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Effect for time range changes - useEffect(() => { - if (!range) { - return; - } - - const currentFrom = roundMsToMin(range.from.valueOf()); - const currentTo = roundMsToMin(range.to.valueOf()); - - if (!lastRangeRef.current) { - lastRangeRef.current = { from: range.from, to: range.to }; - } - - const lastFrom = roundMsToMin(lastRangeRef.current.from.valueOf()); - const lastTo = roundMsToMin(lastRangeRef.current.to.valueOf()); - - if (currentFrom !== lastFrom || currentTo !== lastTo) { - lastRangeRef.current = { from: range.from, to: range.to }; - refreshMetrics(languageProviderInitRef); - } - }, [range, refreshMetrics]); - - // Effect for data changes (refreshing hints) - useEffect(() => { - refreshHint(); - }, [series, refreshHint]); - - return languageProviderInitRef; -} diff --git a/packages/grafana-prometheus/src/constants.ts b/packages/grafana-prometheus/src/constants.ts index 2c8dffa179d..fccc7ddfa0d 100644 --- a/packages/grafana-prometheus/src/constants.ts +++ b/packages/grafana-prometheus/src/constants.ts @@ -1,4 +1,7 @@ // Max number of items (metrics, labels, values) that we display as suggestions. Prevents from running out of memory. +/** + * @deprecated + */ export const SUGGESTIONS_LIMIT = 10000; export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000; @@ -19,6 +22,8 @@ export const EMPTY_SELECTOR = '{}'; export const DEFAULT_SERIES_LIMIT = 40000; +export const DEFAULT_COMPLETION_LIMIT = 1000; + /** * Only for /series endpoint. Don't use this anywhere else as it cause an expensive query */ diff --git a/packages/grafana-prometheus/src/locales/en-US/grafana-prometheus.json b/packages/grafana-prometheus/src/locales/en-US/grafana-prometheus.json index 85eba24f8a4..b94bb4cded3 100644 --- a/packages/grafana-prometheus/src/locales/en-US/grafana-prometheus.json +++ b/packages/grafana-prometheus/src/locales/en-US/grafana-prometheus.json @@ -199,6 +199,10 @@ "tooltip-use-series-endpoint": "Checking this option will favor the series endpoint with {{exampleParameter}} parameter over the label values endpoint with {{exampleParameter}} parameter. While the label values endpoint is considered more performant, some users may prefer the series because it has a POST method while the label values endpoint only has a GET method." } }, + "metrics-browser": { + "disabled-label": "(Disabled)", + "enabled-label": "Metrics browser" + }, "prom-query-legend-editor": { "get-legend-mode-options": { "description-auto": "Only includes unique labels",