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