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:
@@ -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:
|
||||||
|
|||||||
+12
-12
@@ -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',
|
||||||
|
|||||||
+45
-30
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-13
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+123
-20
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user