Prometheus: Performance improvements for high cardinality metrics in code editor (#108341)

* disable auto fetch

* add partially written metric name situation

* add new limit

* add new methods

* rename methods

* implement auto complete after typing at least three letters

* betterer

* cosmetic changes

* partial or full trigger

* cleaner approach

* lint

* fix

* review feedback
This commit is contained in:
ismail simsek
2025-07-28 13:02:16 +02:00
committed by GitHub
parent ce64a78d99
commit c88a6fc2e7
14 changed files with 294 additions and 563 deletions
-3
View File
@@ -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"],
@@ -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<PrometheusDatasource, PromQuery, PromOptions> {
ExtraFieldElement?: ReactNode;
@@ -40,68 +44,32 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
const theme = useTheme2();
const [syntaxLoaded, setSyntaxLoaded] = useState(false);
const [hint, setHint] = useState<QueryHint | null>(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<CancelablePromise<any> | 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 (
<>
<div
@@ -151,11 +114,15 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
<button
className="gf-form-label query-keyword pointer"
onClick={onClickChooserButton}
disabled={buttonDisabled}
disabled={datasource.lookupsDisabled}
type="button"
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton}
>
{chooserText}
{datasource.lookupsDisabled ? (
<Trans i18nKey="grafana-prometheus.metrics-browser.disabled-label">(Disabled)</Trans>
) : (
<Trans i18nKey="grafana-prometheus.metrics-browser.enabled-label">Metrics browser</Trans>
)}
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button>
@@ -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:
@@ -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<Situation['type'], 'AT_ROOT' | 'EMPTY' | 'IN_
const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[];
function getSuggestionCountForSituation(situationType: MetricNameSituation, metricsCount: number): number {
const limitedMetricNamesCount = metricsCount < SUGGESTIONS_LIMIT ? metricsCount : SUGGESTIONS_LIMIT;
const limitedMetricNamesCount = metricsCount < DEFAULT_COMPLETION_LIMIT ? metricsCount : DEFAULT_COMPLETION_LIMIT;
let suggestionsCount = limitedMetricNamesCount + getFunctions().length;
if (situationType === 'EMPTY') {
@@ -185,7 +185,7 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat
const timeRange = getMockTimeRange();
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);
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',
@@ -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<Completion[]> {
let metricNames = await dataProvider.queryMetricNames(timeRange, searchTerm);
if (
config.featureToggles.prometheusCodeModeMetricNamesSearch &&
@@ -110,9 +116,16 @@ const getFunctionCompletions: () => Completion[] = () => {
}));
};
async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
const metricNames = getAllMetricNamesCompletions(dataProvider);
async function getFunctionsOnlyCompletions(): Promise<Completion[]> {
return Promise.resolve(getFunctionCompletions());
}
async function getAllFunctionsAndMetricNamesCompletions(
searchTerm: string | undefined,
dataProvider: DataProvider,
timeRange: TimeRange
): Promise<Completion[]> {
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<string[]> {
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<string[]> {
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<Completion[]> {
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]);
}
@@ -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<HistoryItem<PromQuery>>;
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<string[]> => {
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);
}
@@ -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<typeof setTimeout> | 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<monacoTypes.languages.CompletionList> => {
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<monacoTypes.languages.CompletionList> => {
// 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,
};
}
@@ -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,
@@ -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);
});
});
});
@@ -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]);
}
@@ -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();
});
});
@@ -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<CancelablePromise<unknown> | null>) => Promise<void>,
refreshHint: () => void
) {
const lastRangeRef = useRef<{ from: DateTime; to: DateTime } | null>(null);
const languageProviderInitRef = useRef<CancelablePromise<unknown> | 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;
}
@@ -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
*/
@@ -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",