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": [ "packages/grafana-o11y-ds-frontend/src/createNodeGraphFrames.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [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": [ "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", "0"],
[0, 0, 0, "Direct usage of localStorage is not allowed. import store from @grafana/data instead", "1"], [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 // 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 { 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 { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n'; import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime'; import { reportInteraction } from '@grafana/runtime';
import { clearButtonStyles, Icon, useTheme2 } from '@grafana/ui'; import { clearButtonStyles, Icon, useTheme2 } from '@grafana/ui';
@@ -12,12 +19,9 @@ import { PrometheusDatasource } from '../datasource';
import { getInitHints } from '../query_hints'; import { getInitHints } from '../query_hints';
import { PromOptions, PromQuery } from '../types'; import { PromOptions, PromQuery } from '../types';
import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise';
import { MetricsBrowser } from './metrics-browser/MetricsBrowser'; import { MetricsBrowser } from './metrics-browser/MetricsBrowser';
import { MetricsBrowserProvider } from './metrics-browser/MetricsBrowserContext'; import { MetricsBrowserProvider } from './metrics-browser/MetricsBrowserContext';
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper'; import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper';
import { useMetricsState } from './useMetricsState';
import { usePromQueryFieldEffects } from './usePromQueryFieldEffects';
interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions> { interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions> {
ExtraFieldElement?: ReactNode; ExtraFieldElement?: ReactNode;
@@ -40,68 +44,32 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
const theme = useTheme2(); const theme = useTheme2();
const [syntaxLoaded, setSyntaxLoaded] = useState(false);
const [hint, setHint] = useState<QueryHint | null>(null); const [hint, setHint] = useState<QueryHint | null>(null);
const [labelBrowserVisible, setLabelBrowserVisible] = useState(false); const [labelBrowserVisible, setLabelBrowserVisible] = useState(false);
const updateLanguage = useCallback(() => { const refreshHint = useCallback(
if (languageProvider.retrieveMetrics()) { (series: DataFrame[]) => {
setSyntaxLoaded(true); const initHints = getInitHints(datasource);
} const initHint = initHints[0] ?? null;
}, [languageProvider]);
const refreshMetrics = useCallback( // If no data or empty series, use default hint
async (languageProviderInitRef: MutableRefObject<CancelablePromise<any> | null>) => { if (!data?.series?.length) {
// Cancel any existing initialization using the ref setHint(initHint);
if (languageProviderInitRef.current) {
languageProviderInitRef.current.cancel();
}
if (!languageProvider || !range) {
return; return;
} }
try { const result = isDataFrame(series[0]) ? series.map(toLegacyResponseData) : series;
const initialization = makePromiseCancelable(languageProvider.start(range)); const queryHints = datasource.getQueryHints(query, result);
languageProviderInitRef.current = initialization; let queryHint = queryHints.length > 0 ? queryHints[0] : null;
const remainingTasks = await initialization.promise; setHint(queryHint ?? initHint);
// 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;
}
}, },
[languageProvider, range, updateLanguage] [data, datasource, query]
); );
const refreshHint = useCallback(() => { useEffect(() => {
const initHints = getInitHints(datasource); refreshHint(data?.series ?? []);
const initHint = initHints[0] ?? null; }, [data?.series, refreshHint]);
// 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]);
const onChangeQuery = (value: string, override?: boolean) => { const onChangeQuery = (value: string, override?: boolean) => {
if (!onChange) { if (!onChange) {
@@ -137,11 +105,6 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
onRunQuery(); onRunQuery();
}; };
// Use our custom effects hook
usePromQueryFieldEffects(languageProvider, range, data?.series, refreshMetrics, refreshHint);
const { chooserText, buttonDisabled } = useMetricsState(datasource, languageProvider, syntaxLoaded);
return ( return (
<> <>
<div <div
@@ -151,11 +114,15 @@ export const PromQueryField = (props: PromQueryFieldProps) => {
<button <button
className="gf-form-label query-keyword pointer" className="gf-form-label query-keyword pointer"
onClick={onClickChooserButton} onClick={onClickChooserButton}
disabled={buttonDisabled} disabled={datasource.lookupsDisabled}
type="button" type="button"
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton} 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'} /> <Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button> </button>
@@ -155,7 +155,13 @@ const MonacoQueryField = (props: Props) => {
historyProvider: historyRef.current, historyProvider: historyRef.current,
languageProvider: lpRef.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, // completion-providers in monaco are not registered directly to editor-instances,
// they are registered to languages. this makes it hard for us to have // they are registered to languages. this makes it hard for us to have
@@ -182,7 +188,31 @@ const MonacoQueryField = (props: Props) => {
filteringCompletionProvider 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 // this code makes the editor resize itself so that the content fits
// (it will grow taller when necessary) // (it will grow taller when necessary)
// FIXME: maybe move this functionality into CodeEditor, like: // FIXME: maybe move this functionality into CodeEditor, like:
@@ -1,6 +1,6 @@
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { SUGGESTIONS_LIMIT } from '../../../constants'; import { DEFAULT_COMPLETION_LIMIT } from '../../../constants';
import { getFunctions } from '../../../promql'; import { getFunctions } from '../../../promql';
import { getMockTimeRange } from '../../../test/mocks/datasource'; import { getMockTimeRange } from '../../../test/mocks/datasource';
@@ -12,7 +12,7 @@ const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', '
const dataProviderSettings = { const dataProviderSettings = {
languageProvider: { languageProvider: {
datasource: { datasource: {
metricNamesAutocompleteSuggestionLimit: SUGGESTIONS_LIMIT, metricNamesAutocompleteSuggestionLimit: DEFAULT_COMPLETION_LIMIT,
}, },
queryLabelKeys: jest.fn(), queryLabelKeys: jest.fn(),
queryLabelValues: jest.fn(), queryLabelValues: jest.fn(),
@@ -23,9 +23,9 @@ const dataProviderSettings = {
} as unknown as DataProviderParams; } as unknown as DataProviderParams;
let dataProvider = new DataProvider(dataProviderSettings); let dataProvider = new DataProvider(dataProviderSettings);
const metrics = { 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() { 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[]; const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[];
function getSuggestionCountForSituation(situationType: MetricNameSituation, metricsCount: number): number { 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; let suggestionsCount = limitedMetricNamesCount + getFunctions().length;
if (situationType === 'EMPTY') { if (situationType === 'EMPTY') {
@@ -185,7 +185,7 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat
const timeRange = getMockTimeRange(); const timeRange = getMockTimeRange();
it('should return completions for all metric names when the number of metric names is at or below the limit', async () => { 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 expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.atLimit.length);
const situation: Situation = { const situation: Situation = {
type: situationType, type: situationType,
@@ -232,7 +232,7 @@ describe.each(metricNameCompletionSituations)('metric name completions in situat
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(false); expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(false);
// Cross the metric names threshold, without text input // 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(''); dataProvider.monacoSettings.setInputInRange('');
await getCompletions(situation, dataProvider, timeRange); await getCompletions(situation, dataProvider, timeRange);
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true); 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']; 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) // Test with a complex query (> 4 terms)
dataProvider.monacoSettings.setInputInRange('metric name 1 with extra terms more'); dataProvider.monacoSettings.setInputInRange('metric name 1 with extra terms more');
@@ -284,7 +284,7 @@ describe('Label value completions', () => {
getAllMetricNames: jest.fn(), getAllMetricNames: jest.fn(),
metricNamesToMetrics: jest.fn(), metricNamesToMetrics: jest.fn(),
getHistory: 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: { monacoSettings: {
setInputInRange: jest.fn(), setInputInRange: jest.fn(),
inputInRange: '', inputInRange: '',
@@ -397,7 +397,7 @@ describe('Label value completions', () => {
const timeRange = getMockTimeRange(); const timeRange = getMockTimeRange();
it('should handle empty values', async () => { it('should handle empty values', async () => {
jest.spyOn(dataProvider, 'getLabelValues').mockResolvedValue(['']); jest.spyOn(dataProvider, 'queryLabelValues').mockResolvedValue(['']);
const situation: Situation = { const situation: Situation = {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
@@ -412,7 +412,7 @@ describe('Label value completions', () => {
}); });
it('should handle values with multiple special characters', async () => { 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 = { const situation: Situation = {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
@@ -427,7 +427,7 @@ describe('Label value completions', () => {
}); });
it('should handle non-string values', async () => { 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 = { const situation: Situation = {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME', type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME',
@@ -5,11 +5,13 @@ import { languages } from 'monaco-editor';
import { TimeRange } from '@grafana/data'; import { TimeRange } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { DEFAULT_COMPLETION_LIMIT } from '../../../constants';
import { escapeLabelValueInExactSelector, prometheusRegularEscape } from '../../../escaping'; import { escapeLabelValueInExactSelector, prometheusRegularEscape } from '../../../escaping';
import { getFunctions } from '../../../promql'; import { getFunctions } from '../../../promql';
import { isValidLegacyName } from '../../../utf8_support'; import { isValidLegacyName } from '../../../utf8_support';
import { DataProvider } from './data_provider'; import { DataProvider } from './data_provider';
import { TriggerType } from './monaco-completion-provider';
import type { Label, Situation } from './situation'; import type { Label, Situation } from './situation';
import { NeverCaseError } from './util'; 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 // 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 // we order items like: history, functions, metrics
function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] { async function getAllMetricNamesCompletions(
let metricNames = dataProvider.getAllMetricNames(); searchTerm: string | undefined,
dataProvider: DataProvider,
timeRange: TimeRange
): Promise<Completion[]> {
let metricNames = await dataProvider.queryMetricNames(timeRange, searchTerm);
if ( if (
config.featureToggles.prometheusCodeModeMetricNamesSearch && config.featureToggles.prometheusCodeModeMetricNamesSearch &&
@@ -110,9 +116,16 @@ const getFunctionCompletions: () => Completion[] = () => {
})); }));
}; };
async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> { async function getFunctionsOnlyCompletions(): Promise<Completion[]> {
const metricNames = getAllMetricNamesCompletions(dataProvider); 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]; 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]; const allLabels = [...labels];
// we transform the metricName to a label, if it exists // we transform the metricName to a label, if it exists
@@ -165,19 +182,13 @@ async function getLabelNames(
dataProvider: DataProvider, dataProvider: DataProvider,
timeRange: TimeRange timeRange: TimeRange
): Promise<string[]> { ): Promise<string[]> {
if (metric === undefined && otherLabels.length === 0) { const selector = makeSelector(metric, otherLabels);
// if there is no filtering, we have to use a special endpoint const labelNames = await dataProvider.queryLabelKeys(timeRange, selector, DEFAULT_COMPLETION_LIMIT);
return Promise.resolve(dataProvider.getAllLabelNames()); // Exclude __name__ from output
} else { otherLabels.push({ name: '__name__', value: '', op: '!=' });
const selector = makeSelector(metric, otherLabels); const usedLabelNames = new Set(otherLabels.map((l) => l.name));
const labelNames = await dataProvider.getSeriesLabels(timeRange, selector); // names used in the query
return labelNames.filter((l) => !usedLabelNames.has(l));
// 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( async function getLabelNamesForCompletions(
@@ -232,13 +243,8 @@ async function getLabelValues(
dataProvider: DataProvider, dataProvider: DataProvider,
timeRange: TimeRange timeRange: TimeRange
): Promise<string[]> { ): Promise<string[]> {
if (metric === undefined && otherLabels.length === 0) { const selector = makeSelector(metric, otherLabels);
// if there is no filtering, we have to use a special endpoint return await dataProvider.queryLabelValues(timeRange, labelName, selector);
return dataProvider.getLabelValues(timeRange, labelName);
} else {
const selector = makeSelector(metric, otherLabels);
return await dataProvider.getSeriesValues(timeRange, labelName, selector);
}
} }
async function getLabelValuesForMetricCompletions( async function getLabelValuesForMetricCompletions(
@@ -262,21 +268,30 @@ function formatLabelValueForCompletion(value: string, betweenQuotes: boolean): s
return betweenQuotes ? text : `"${text}"`; return betweenQuotes ? text : `"${text}"`;
} }
export function getCompletions( export async function getCompletions(
situation: Situation, situation: Situation,
dataProvider: DataProvider, dataProvider: DataProvider,
timeRange: TimeRange timeRange: TimeRange,
searchTerm?: string,
triggerType: TriggerType = 'full'
): Promise<Completion[]> { ): Promise<Completion[]> {
switch (situation.type) { switch (situation.type) {
case 'IN_DURATION': case 'IN_DURATION':
return Promise.resolve(DURATION_COMPLETIONS); return Promise.resolve(DURATION_COMPLETIONS);
case 'IN_FUNCTION': case 'IN_FUNCTION':
return getAllFunctionsAndMetricNamesCompletions(dataProvider); return triggerType === 'full'
? getAllFunctionsAndMetricNamesCompletions(searchTerm, dataProvider, timeRange)
: getFunctionsOnlyCompletions();
case 'AT_ROOT': { case 'AT_ROOT': {
return getAllFunctionsAndMetricNamesCompletions(dataProvider); return triggerType === 'full'
? getAllFunctionsAndMetricNamesCompletions(searchTerm, dataProvider, timeRange)
: getFunctionsOnlyCompletions();
} }
case 'EMPTY': { case 'EMPTY': {
const metricNames = getAllMetricNamesCompletions(dataProvider); if (triggerType === 'partial') {
return Promise.resolve(getFunctionCompletions());
}
const metricNames = await getAllMetricNamesCompletions(searchTerm, dataProvider, timeRange);
const historyCompletions = getAllHistoryCompletions(dataProvider); const historyCompletions = getAllHistoryCompletions(dataProvider);
return Promise.resolve([...historyCompletions, ...getFunctionCompletions(), ...metricNames]); return Promise.resolve([...historyCompletions, ...getFunctionCompletions(), ...metricNames]);
} }
@@ -1,9 +1,10 @@
import { HistoryItem } from '@grafana/data'; import { HistoryItem, TimeRange } from '@grafana/data';
import type { Monaco } from '@grafana/ui'; // used in TSDoc `@link` below
import { DEFAULT_COMPLETION_LIMIT, METRIC_LABEL } from '../../../constants';
import { type PrometheusLanguageProviderInterface } from '../../../language_provider'; import { type PrometheusLanguageProviderInterface } from '../../../language_provider';
import { removeQuotesIfExist } from '../../../language_utils';
import { PromQuery } from '../../../types'; import { PromQuery } from '../../../types';
import { isValidLegacyName } from '../../../utf8_support'; import { escapeForUtf8Support, isValidLegacyName } from '../../../utf8_support';
export const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete'; export const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete';
@@ -38,11 +39,10 @@ export interface DataProviderParams {
export class DataProvider { export class DataProvider {
readonly languageProvider: PrometheusLanguageProviderInterface; readonly languageProvider: PrometheusLanguageProviderInterface;
readonly historyProvider: Array<HistoryItem<PromQuery>>; readonly historyProvider: Array<HistoryItem<PromQuery>>;
readonly getSeriesLabels: typeof this.languageProvider.queryLabelKeys;
readonly getSeriesValues: typeof this.languageProvider.queryLabelValues; readonly metricNamesSuggestionLimit: number = DEFAULT_COMPLETION_LIMIT;
readonly getAllLabelNames: typeof this.languageProvider.retrieveLabelKeys; readonly queryLabelKeys: typeof this.languageProvider.queryLabelKeys;
readonly getLabelValues: typeof this.languageProvider.queryLabelValues; readonly queryLabelValues: typeof this.languageProvider.queryLabelValues;
readonly metricNamesSuggestionLimit: number;
/** /**
* The text that's been typed so far within the current {@link Monaco.Range | Range}. * 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.languageProvider = params.languageProvider;
this.historyProvider = params.historyProvider; this.historyProvider = params.historyProvider;
this.inputInRange = ''; this.inputInRange = '';
this.metricNamesSuggestionLimit = this.languageProvider.datasource.metricNamesAutocompleteSuggestionLimit;
this.suggestionsIncomplete = false; this.suggestionsIncomplete = false;
this.getSeriesLabels = this.languageProvider.queryLabelKeys.bind(this.languageProvider);
this.getSeriesValues = this.languageProvider.queryLabelValues.bind(this.languageProvider); this.queryLabelKeys = this.languageProvider.queryLabelKeys.bind(this.languageProvider);
this.getAllLabelNames = this.languageProvider.retrieveLabelKeys.bind(this.languageProvider); this.queryLabelValues = this.languageProvider.queryLabelValues.bind(this.languageProvider);
this.getLabelValues = 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[] { getHistory(): string[] {
return this.historyProvider.map((h) => h.query.expr).filter(Boolean); return this.historyProvider.map((h) => h.query.expr).filter(Boolean);
} }
@@ -7,6 +7,8 @@ import { DataProvider } from './data_provider';
import { getSituation } from './situation'; import { getSituation } from './situation';
import { NeverCaseError } from './util'; import { NeverCaseError } from './util';
export type TriggerType = 'partial' | 'full';
export function getSuggestOptions(): monacoTypes.editor.ISuggestOptions { export function getSuggestOptions(): monacoTypes.editor.ISuggestOptions {
return { return {
// monaco-editor sometimes provides suggestions automatically, i am not // 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( export function getCompletionProvider(
monaco: Monaco, monaco: Monaco,
dataProvider: DataProvider, dataProvider: DataProvider,
timeRange: TimeRange 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 = ( const provideCompletionItems = (
model: monacoTypes.editor.ITextModel, model: monacoTypes.editor.ITextModel,
position: monacoTypes.Position position: monacoTypes.Position,
context: monacoTypes.languages.CompletionContext
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> => { ): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> => {
const word = model.getWordAtPosition(position); const word = model.getWordAtPosition(position);
const range = const range =
@@ -66,13 +111,67 @@ export function getCompletionProvider(
endColumn: word.endColumn, endColumn: word.endColumn,
}) })
: monaco.Range.fromPositions(position); : 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` // documentation says `position` will be "adjusted" in `getOffsetAt`
// i don't know what that means, to be sure i clone it // i don't know what that means, to be sure i clone it
const positionClone = { const positionClone = {
column: position.column, column: position.column,
lineNumber: position.lineNumber, lineNumber: position.lineNumber,
}; };
dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range)); dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range));
// Check to see if the browser supports window.getSelection() // Check to see if the browser supports window.getSelection()
@@ -87,7 +186,9 @@ export function getCompletionProvider(
const offset = model.getOffsetAt(positionClone); const offset = model.getOffsetAt(positionClone);
const situation = getSituation(model.getValue(), offset); const situation = getSituation(model.getValue(), offset);
const completionsPromise = const completionsPromise =
situation != null ? getCompletions(situation, dataProvider, timeRange) : Promise.resolve([]); situation != null
? getCompletions(situation, dataProvider, timeRange, wordText, triggerType)
: Promise.resolve([]);
return completionsPromise.then((items) => { return completionsPromise.then((items) => {
// monaco by-default alphabetically orders the items. // monaco by-default alphabetically orders the items.
@@ -95,27 +196,29 @@ export function getCompletionProvider(
// so that monaco keeps the order we use // so that monaco keeps the order we use
const maxIndexDigits = items.length.toString().length; const maxIndexDigits = items.length.toString().length;
const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => ({ 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, range,
command: item.triggerOnInsert label: item.label,
? { detail: item.detail,
id: 'editor.action.triggerSuggest', insertText: item.insertText,
title: '', documentation: item.documentation,
} insertTextRules: item.insertTextRules,
: undefined, 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 { return {
triggerCharacters: ['{', ',', '[', '(', '=', '~', ' ', '"'], provider: {
provideCompletionItems, triggerCharacters: ['{', ',', '[', '(', '=', '~', ' ', '"'],
provideCompletionItems,
},
state,
}; };
} }
@@ -186,6 +186,11 @@ const RESOLVERS: Resolver[] = [
path: [PromQL], path: [PromQL],
fun: resolveTopLevel, fun: resolveTopLevel,
}, },
{
// Partially written metric name
path: [Identifier, VectorSelector, PromQL],
fun: resolveTopLevel,
},
{ {
path: [FunctionCallBody], path: [FunctionCallBody],
fun: resolveInFunction, 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. // 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 SUGGESTIONS_LIMIT = 10000;
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000; 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_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 * 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." "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": { "prom-query-legend-editor": {
"get-legend-mode-options": { "get-legend-mode-options": {
"description-auto": "Only includes unique labels", "description-auto": "Only includes unique labels",