mirror of
https://github.com/grafana/grafana.git
synced 2026-08-26 13:27:30 -05:00
Prometheus: Fuzzy search for metric names in Code Mode (#85396)
* perf: limit metric names in Code Mode suggestions * feat: configurable metric names limit * feat: code mode autocomplete info/disclaimer * chore: put new functionality behind new feature toggle * refactor: avoid type assertions * refactor: avoid explicit `any` refactor: type guards * refactor: type guards * chore: add testdata results * fix: add missing feature toggle guard * perf: prefer array access to `Array.prototype.at` * test: add missing config override * test: refactor for brevity & clarity * perf: avoid unnecessary mapping * chore: undo testdata changes * fix: use correct limit; perf optimizations * refactor: avoid unnecessary `async`s * types: simplify * test: add missing tests * fix: avoid hardcoding * test: update mock path * docs: fix typo style: remove formatting artifact style: remove formatting artifact style: remove formatting artifact * fix: event scope regression * style: refactor for clarity * refactor: prefer `useCallback` to in-effect handler * refactor: simplify & broaden `filter` * refactor: rename file to keep with conventions * chore: mirror Prometheus package changes in app * refactor: prefer no `@ts-ignore` * chore: update betterer results * docs: use type in TSDoc `@link` without `@ts-ignore` * test: add missing provider * test: fix jest mock path * fix: display disclaimer in empty input case
This commit is contained in:
@@ -6442,6 +6442,8 @@ exports[`no gf-form usage`] = {
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"packages/grafana-prometheus/src/querybuilder/components/PromQueryCodeEditor.tsx:5381": [
|
||||
@@ -6965,6 +6967,8 @@ exports[`no gf-form usage`] = {
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
|
||||
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
|
||||
],
|
||||
"public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx:5381": [
|
||||
|
||||
@@ -153,6 +153,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `kubernetesSnapshots` | Routes snapshot requests from /api to the /apis endpoint |
|
||||
| `cachingOptimizeSerializationMemoryUsage` | If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. |
|
||||
| `prometheusPromQAIL` | Prometheus and AI/ML to assist users in creating a query |
|
||||
| `prometheusCodeModeMetricNamesSearch` | Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names |
|
||||
| `alertmanagerRemoteSecondary` | Enable Grafana to sync configuration and state with a remote Alertmanager. |
|
||||
| `alertmanagerRemotePrimary` | Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager. |
|
||||
| `alertmanagerRemoteOnly` | Disable the internal Alertmanager and only use the external one defined. |
|
||||
|
||||
@@ -133,6 +133,7 @@ export interface FeatureToggles {
|
||||
panelTitleSearchInV1?: boolean;
|
||||
managedPluginsInstall?: boolean;
|
||||
prometheusPromQAIL?: boolean;
|
||||
prometheusCodeModeMetricNamesSearch?: boolean;
|
||||
addFieldFromCalculationStatFunctions?: boolean;
|
||||
alertmanagerRemoteSecondary?: boolean;
|
||||
alertmanagerRemotePrimary?: boolean;
|
||||
|
||||
@@ -78,6 +78,7 @@ export const Components = {
|
||||
httpMethod: 'data-testid http method',
|
||||
exemplarsAddButton: 'data-testid Add exemplar config button',
|
||||
internalLinkSwitch: 'data-testid Internal link switch',
|
||||
codeModeMetricNamesSuggestionLimit: 'data-testid code mode metric names suggestion limit',
|
||||
},
|
||||
queryEditor: {
|
||||
// kickstart: '', see QueryBuilder queryPatterns below
|
||||
@@ -101,6 +102,7 @@ export const Components = {
|
||||
},
|
||||
code: {
|
||||
queryField: 'data-testid prometheus query field',
|
||||
metricsCountInfo: 'data-testid metrics count disclaimer',
|
||||
metricsBrowser: {
|
||||
openButton: 'data-testid open metrics browser',
|
||||
selectMetric: 'data-testid select a metric',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Monaco, monacoTypes, ReactMonacoEditor, useTheme2 } from '@grafana/ui';
|
||||
import { Props } from './MonacoQueryFieldProps';
|
||||
import { getOverrideServices } from './getOverrideServices';
|
||||
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
|
||||
import { DataProvider } from './monaco-completion-provider/data_provider';
|
||||
import { placeHolderScopedVars, validateQuery } from './monaco-completion-provider/validation';
|
||||
import { language, languageConfiguration } from './promql';
|
||||
|
||||
@@ -143,41 +144,10 @@ const MonacoQueryField = (props: Props) => {
|
||||
editor.onDidFocusEditorText(() => {
|
||||
isEditorFocused.set(true);
|
||||
});
|
||||
|
||||
// we construct a DataProvider object
|
||||
const getHistory = () =>
|
||||
Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined));
|
||||
|
||||
const getAllMetricNames = () => {
|
||||
const { metrics, metricsMetadata } = lpRef.current;
|
||||
const result = metrics.map((m) => {
|
||||
const metaItem = metricsMetadata?.[m];
|
||||
return {
|
||||
name: m,
|
||||
help: metaItem?.help ?? '',
|
||||
type: metaItem?.type ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
return Promise.resolve(result);
|
||||
};
|
||||
|
||||
const getAllLabelNames = () => Promise.resolve(lpRef.current.getLabelKeys());
|
||||
|
||||
const getLabelValues = (labelName: string) => lpRef.current.getLabelValues(labelName);
|
||||
|
||||
const getSeriesValues = lpRef.current.getSeriesValues;
|
||||
|
||||
const getSeriesLabels = lpRef.current.getSeriesLabels;
|
||||
|
||||
const dataProvider = {
|
||||
getHistory,
|
||||
getAllMetricNames,
|
||||
getAllLabelNames,
|
||||
getLabelValues,
|
||||
getSeriesValues,
|
||||
getSeriesLabels,
|
||||
};
|
||||
const dataProvider = new DataProvider({
|
||||
historyProvider: historyRef.current,
|
||||
languageProvider: lpRef.current,
|
||||
});
|
||||
const completionProvider = getCompletionProvider(monaco, dataProvider);
|
||||
|
||||
// completion-providers in monaco are not registered directly to editor-instances,
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { SUGGESTIONS_LIMIT } from '../../../language_provider';
|
||||
import { FUNCTIONS } from '../../../promql';
|
||||
|
||||
import { getCompletions } from './completions';
|
||||
import { DataProvider, DataProviderParams } from './data_provider';
|
||||
import type { Situation } from './situation';
|
||||
|
||||
const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', 'previous_metric_name_3'];
|
||||
const dataProviderSettings = {
|
||||
languageProvider: {
|
||||
datasource: {
|
||||
metricNamesAutocompleteSuggestionLimit: SUGGESTIONS_LIMIT,
|
||||
},
|
||||
getLabelKeys: jest.fn(),
|
||||
getLabelValues: jest.fn(),
|
||||
getSeriesLabels: jest.fn(),
|
||||
getSeriesValues: jest.fn(),
|
||||
metrics: [],
|
||||
metricsMetadata: {},
|
||||
},
|
||||
historyProvider: history.map((expr, idx) => ({ query: { expr, refId: 'some-ref' }, ts: idx })),
|
||||
} as unknown as DataProviderParams;
|
||||
let dataProvider = new DataProvider(dataProviderSettings);
|
||||
const metrics = {
|
||||
beyondLimit: Array.from(Array(SUGGESTIONS_LIMIT + 1), (_, i) => `metric_name_${i}`),
|
||||
get atLimit() {
|
||||
return this.beyondLimit.slice(0, SUGGESTIONS_LIMIT - 1);
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dataProvider = new DataProvider(dataProviderSettings);
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusCodeModeMetricNamesSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
type MetricNameSituation = Extract<Situation['type'], 'AT_ROOT' | 'EMPTY' | 'IN_FUNCTION'>;
|
||||
const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[];
|
||||
|
||||
function getSuggestionCountForSituation(situationType: MetricNameSituation, metricsCount: number): number {
|
||||
const limitedMetricNamesCount = metricsCount < SUGGESTIONS_LIMIT ? metricsCount : SUGGESTIONS_LIMIT;
|
||||
let suggestionsCount = limitedMetricNamesCount + FUNCTIONS.length;
|
||||
|
||||
if (situationType === 'EMPTY') {
|
||||
suggestionsCount += history.length;
|
||||
}
|
||||
|
||||
return suggestionsCount;
|
||||
}
|
||||
|
||||
describe.each(metricNameCompletionSituations)('metric name completions in situation %s', (situationType) => {
|
||||
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);
|
||||
const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.atLimit.length);
|
||||
const situation: Situation = {
|
||||
type: situationType,
|
||||
};
|
||||
|
||||
// No text input
|
||||
dataProvider.monacoSettings.setInputInRange('');
|
||||
let completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions).toHaveLength(expectedCompletionsCount);
|
||||
|
||||
// With text input (use fuzzy search)
|
||||
dataProvider.monacoSettings.setInputInRange('name_1');
|
||||
completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount);
|
||||
});
|
||||
|
||||
it('should limit completions for metric names when the number of metric names is greater than the limit', async () => {
|
||||
const situation: Situation = {
|
||||
type: situationType,
|
||||
};
|
||||
const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length);
|
||||
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.beyondLimit);
|
||||
|
||||
// No text input
|
||||
dataProvider.monacoSettings.setInputInRange('');
|
||||
let completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions).toHaveLength(expectedCompletionsCount);
|
||||
|
||||
// With text input (use fuzzy search)
|
||||
dataProvider.monacoSettings.setInputInRange('name_1');
|
||||
completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount);
|
||||
});
|
||||
|
||||
it('should enable autocomplete suggestions update when the number of metric names is greater than the limit', async () => {
|
||||
const situation: Situation = {
|
||||
type: situationType,
|
||||
};
|
||||
|
||||
// Do not cross the metrics names threshold
|
||||
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.atLimit);
|
||||
dataProvider.monacoSettings.setInputInRange('name_1');
|
||||
await getCompletions(situation, dataProvider);
|
||||
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(false);
|
||||
|
||||
// Cross the metric names threshold, without text input
|
||||
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit);
|
||||
dataProvider.monacoSettings.setInputInRange('');
|
||||
await getCompletions(situation, dataProvider);
|
||||
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true);
|
||||
|
||||
// Cross the metric names threshold, with text input
|
||||
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit);
|
||||
dataProvider.monacoSettings.setInputInRange('name_1');
|
||||
await getCompletions(situation, dataProvider);
|
||||
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true);
|
||||
});
|
||||
});
|
||||
+37
-26
@@ -1,6 +1,11 @@
|
||||
import UFuzzy from '@leeoniya/ufuzzy';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { escapeLabelValueInExactSelector } from '../../../language_utils';
|
||||
import { FUNCTIONS } from '../../../promql';
|
||||
|
||||
import { DataProvider } from './data_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
|
||||
@@ -16,26 +21,31 @@ type Completion = {
|
||||
triggerOnInsert?: boolean;
|
||||
};
|
||||
|
||||
type Metric = {
|
||||
name: string;
|
||||
help: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type DataProvider = {
|
||||
getHistory: () => Promise<string[]>;
|
||||
getAllMetricNames: () => Promise<Metric[]>;
|
||||
getAllLabelNames: () => Promise<string[]>;
|
||||
getLabelValues: (labelName: string) => Promise<string[]>;
|
||||
getSeriesValues: (name: string, match: string) => Promise<string[]>;
|
||||
getSeriesLabels: (selector: string, otherLabels: Label[]) => Promise<string[]>;
|
||||
};
|
||||
const metricNamesSearchClient = new UFuzzy({ intraMode: 1 });
|
||||
|
||||
// we order items like: history, functions, metrics
|
||||
function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] {
|
||||
let metricNames = dataProvider.getAllMetricNames();
|
||||
|
||||
async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
|
||||
const metrics = await dataProvider.getAllMetricNames();
|
||||
return metrics.map((metric) => ({
|
||||
if (
|
||||
config.featureToggles.prometheusCodeModeMetricNamesSearch &&
|
||||
metricNames.length > dataProvider.metricNamesSuggestionLimit
|
||||
) {
|
||||
const { monacoSettings } = dataProvider;
|
||||
monacoSettings.enableAutocompleteSuggestionsUpdate();
|
||||
|
||||
if (monacoSettings.inputInRange) {
|
||||
metricNames =
|
||||
metricNamesSearchClient
|
||||
.filter(metricNames, monacoSettings.inputInRange)
|
||||
?.slice(0, dataProvider.metricNamesSuggestionLimit)
|
||||
.map((idx) => metricNames[idx]) ?? [];
|
||||
} else {
|
||||
metricNames = metricNames.slice(0, dataProvider.metricNamesSuggestionLimit);
|
||||
}
|
||||
}
|
||||
|
||||
return dataProvider.metricNamesToMetrics(metricNames).map((metric) => ({
|
||||
type: 'METRIC_NAME',
|
||||
label: metric.name,
|
||||
insertText: metric.name,
|
||||
@@ -53,7 +63,8 @@ const FUNCTION_COMPLETIONS: Completion[] = FUNCTIONS.map((f) => ({
|
||||
}));
|
||||
|
||||
async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
|
||||
const metricNames = await getAllMetricNamesCompletions(dataProvider);
|
||||
const metricNames = getAllMetricNamesCompletions(dataProvider);
|
||||
|
||||
return [...FUNCTION_COMPLETIONS, ...metricNames];
|
||||
}
|
||||
|
||||
@@ -73,10 +84,10 @@ const DURATION_COMPLETIONS: Completion[] = [
|
||||
insertText: text,
|
||||
}));
|
||||
|
||||
async function getAllHistoryCompletions(dataProvider: DataProvider): Promise<Completion[]> {
|
||||
function getAllHistoryCompletions(dataProvider: DataProvider): Completion[] {
|
||||
// function getAllHistoryCompletions(queryHistory: PromHistoryItem[]): Completion[] {
|
||||
// NOTE: the typescript types are wrong. historyItem.query.expr can be undefined
|
||||
const allHistory = await dataProvider.getHistory();
|
||||
const allHistory = dataProvider.getHistory();
|
||||
// FIXME: find a better history-limit
|
||||
return allHistory.slice(0, 10).map((expr) => ({
|
||||
type: 'HISTORY',
|
||||
@@ -107,7 +118,7 @@ async function getLabelNames(
|
||||
): Promise<string[]> {
|
||||
if (metric === undefined && otherLabels.length === 0) {
|
||||
// if there is no filtering, we have to use a special endpoint
|
||||
return dataProvider.getAllLabelNames();
|
||||
return Promise.resolve(dataProvider.getAllLabelNames());
|
||||
} else {
|
||||
const selector = makeSelector(metric, otherLabels);
|
||||
return await dataProvider.getSeriesLabels(selector, otherLabels);
|
||||
@@ -176,19 +187,19 @@ async function getLabelValuesForMetricCompletions(
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
|
||||
export function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
|
||||
switch (situation.type) {
|
||||
case 'IN_DURATION':
|
||||
return DURATION_COMPLETIONS;
|
||||
return Promise.resolve(DURATION_COMPLETIONS);
|
||||
case 'IN_FUNCTION':
|
||||
return getAllFunctionsAndMetricNamesCompletions(dataProvider);
|
||||
case 'AT_ROOT': {
|
||||
return getAllFunctionsAndMetricNamesCompletions(dataProvider);
|
||||
}
|
||||
case 'EMPTY': {
|
||||
const metricNames = await getAllMetricNamesCompletions(dataProvider);
|
||||
const historyCompletions = await getAllHistoryCompletions(dataProvider);
|
||||
return [...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames];
|
||||
const metricNames = getAllMetricNamesCompletions(dataProvider);
|
||||
const historyCompletions = getAllHistoryCompletions(dataProvider);
|
||||
return Promise.resolve([...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]);
|
||||
}
|
||||
case 'IN_LABEL_SELECTOR_NO_LABEL_NAME':
|
||||
return getLabelNamesForSelectorCompletions(situation.metricName, situation.otherLabels, dataProvider);
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { HistoryItem } from '@grafana/data';
|
||||
import type { Monaco } from '@grafana/ui'; // used in TSDoc `@link` below
|
||||
|
||||
import PromQlLanguageProvider from '../../../language_provider';
|
||||
import { PromQuery } from '../../../types';
|
||||
|
||||
export const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete';
|
||||
|
||||
export type SuggestionsIncompleteEvent = CustomEvent<{
|
||||
limit: number;
|
||||
datasourceUid: string;
|
||||
}>;
|
||||
|
||||
export function isSuggestionsIncompleteEvent(e: Event): e is SuggestionsIncompleteEvent {
|
||||
return (
|
||||
e.type === CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT &&
|
||||
'detail' in e &&
|
||||
typeof e.detail === 'object' &&
|
||||
e.detail !== null &&
|
||||
'limit' in e.detail &&
|
||||
'datasourceUid' in e.detail
|
||||
);
|
||||
}
|
||||
|
||||
interface Metric {
|
||||
name: string;
|
||||
help: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface DataProviderParams {
|
||||
languageProvider: PromQlLanguageProvider;
|
||||
historyProvider: Array<HistoryItem<PromQuery>>;
|
||||
}
|
||||
|
||||
export class DataProvider {
|
||||
readonly languageProvider: PromQlLanguageProvider;
|
||||
readonly historyProvider: Array<HistoryItem<PromQuery>>;
|
||||
readonly getSeriesLabels: typeof this.languageProvider.getSeriesLabels;
|
||||
readonly getSeriesValues: typeof this.languageProvider.getSeriesValues;
|
||||
readonly getAllLabelNames: typeof this.languageProvider.getLabelKeys;
|
||||
readonly getLabelValues: typeof this.languageProvider.getLabelValues;
|
||||
readonly metricNamesSuggestionLimit: number;
|
||||
/**
|
||||
* The text that's been typed so far within the current {@link Monaco.Range | Range}.
|
||||
*
|
||||
* @remarks
|
||||
* This is useful with fuzzy searching items to provide as Monaco autocomplete suggestions.
|
||||
*/
|
||||
private inputInRange: string;
|
||||
private suggestionsIncomplete: boolean;
|
||||
|
||||
constructor(params: DataProviderParams) {
|
||||
this.languageProvider = params.languageProvider;
|
||||
this.historyProvider = params.historyProvider;
|
||||
this.inputInRange = '';
|
||||
this.metricNamesSuggestionLimit = this.languageProvider.datasource.metricNamesAutocompleteSuggestionLimit;
|
||||
this.suggestionsIncomplete = false;
|
||||
this.getSeriesLabels = this.languageProvider.getSeriesLabels.bind(this.languageProvider);
|
||||
this.getSeriesValues = this.languageProvider.getSeriesValues.bind(this.languageProvider);
|
||||
this.getAllLabelNames = this.languageProvider.getLabelKeys.bind(this.languageProvider);
|
||||
this.getLabelValues = this.languageProvider.getLabelValues.bind(this.languageProvider);
|
||||
}
|
||||
|
||||
getHistory(): string[] {
|
||||
return this.historyProvider.map((h) => h.query.expr).filter(Boolean);
|
||||
}
|
||||
|
||||
getAllMetricNames(): string[] {
|
||||
return this.languageProvider.metrics;
|
||||
}
|
||||
|
||||
metricNamesToMetrics(metricNames: string[]): Metric[] {
|
||||
const { metricsMetadata } = this.languageProvider;
|
||||
const result: Metric[] = metricNames.map((m) => {
|
||||
const metaItem = metricsMetadata?.[m];
|
||||
return {
|
||||
name: m,
|
||||
help: metaItem?.help ?? '',
|
||||
type: metaItem?.type ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private enableAutocompleteSuggestionsUpdate(): void {
|
||||
this.suggestionsIncomplete = true;
|
||||
dispatchEvent(
|
||||
new CustomEvent(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, {
|
||||
detail: { limit: this.metricNamesSuggestionLimit, datasourceUid: this.languageProvider.datasource.uid },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private setInputInRange(textInput: string): void {
|
||||
this.inputInRange = textInput;
|
||||
}
|
||||
|
||||
get monacoSettings() {
|
||||
return {
|
||||
/**
|
||||
* Enable autocomplete suggestions update on every input change.
|
||||
*
|
||||
* @remarks
|
||||
* If fuzzy search is used in `getCompletions` to trim down results to improve performance,
|
||||
* we need to instruct Monaco to update the completions on every input change, so that the
|
||||
* completions reflect the current input.
|
||||
*/
|
||||
enableAutocompleteSuggestionsUpdate: this.enableAutocompleteSuggestionsUpdate.bind(this),
|
||||
inputInRange: this.inputInRange,
|
||||
setInputInRange: this.setInputInRange.bind(this),
|
||||
suggestionsIncomplete: this.suggestionsIncomplete,
|
||||
};
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
import type { Monaco, monacoTypes } from '@grafana/ui';
|
||||
|
||||
import { CompletionType, DataProvider, getCompletions } from './completions';
|
||||
import { CompletionType, getCompletions } from './completions';
|
||||
import { DataProvider } from './data_provider';
|
||||
import { getSituation } from './situation';
|
||||
import { NeverCaseError } from './util';
|
||||
|
||||
@@ -69,6 +70,7 @@ export function getCompletionProvider(
|
||||
column: position.column,
|
||||
lineNumber: position.lineNumber,
|
||||
};
|
||||
dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range));
|
||||
|
||||
// Check to see if the browser supports window.getSelection()
|
||||
if (window.getSelection) {
|
||||
@@ -82,6 +84,7 @@ export function getCompletionProvider(
|
||||
const offset = model.getOffsetAt(positionClone);
|
||||
const situation = getSituation(model.getValue(), offset);
|
||||
const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]);
|
||||
|
||||
return completionsPromise.then((items) => {
|
||||
// monaco by-default alphabetically orders the items.
|
||||
// to stop it, we use a number-as-string sortkey,
|
||||
@@ -102,7 +105,7 @@ export function getCompletionProvider(
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
return { suggestions };
|
||||
return { suggestions, incomplete: dataProvider.monacoSettings.suggestionsIncomplete };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React, { SyntheticEvent } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { getValueFromEventItem, PromSettings } from './PromSettings';
|
||||
import { countError, getValueFromEventItem, PromSettings } from './PromSettings';
|
||||
import { createDefaultConfigOptions } from './mocks';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusCodeModeMetricNamesSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('PromSettings', () => {
|
||||
describe('getValueFromEventItem', () => {
|
||||
describe('when called with undefined', () => {
|
||||
@@ -61,5 +69,46 @@ describe('PromSettings', () => {
|
||||
render(<PromSettings onOptionsChange={() => {}} options={options} />);
|
||||
expect(screen.getByText('GET')).toBeInTheDocument();
|
||||
});
|
||||
it('should show a valid metric name count if codeModeMetricNamesSuggestionLimit is configured correctly', () => {
|
||||
const options = defaultProps;
|
||||
|
||||
const { getByTestId, queryByText } = render(<PromSettings onOptionsChange={() => {}} options={options} />);
|
||||
const input = getByTestId(
|
||||
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
|
||||
);
|
||||
|
||||
// Non-negative integer
|
||||
fireEvent.change(input, { target: { value: '3000' } });
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText(countError)).not.toBeInTheDocument();
|
||||
|
||||
// Non-negative integer with scientific notation
|
||||
fireEvent.change(input, { target: { value: '1e5' } });
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText(countError)).not.toBeInTheDocument();
|
||||
|
||||
// Non-negative integer with decimal scientific notation
|
||||
fireEvent.change(input, { target: { value: '1.4e4' } });
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText(countError)).not.toBeInTheDocument();
|
||||
});
|
||||
it('should show the expected error when an invalid value is provided for codeModeMetricNamesSuggestionLimit', () => {
|
||||
const options = defaultProps;
|
||||
|
||||
const { getByTestId, queryByText } = render(<PromSettings onOptionsChange={() => {}} options={options} />);
|
||||
const input = getByTestId(
|
||||
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
|
||||
);
|
||||
|
||||
// No negative values
|
||||
fireEvent.change(input, { target: { value: '-50' } });
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText(countError)).toBeInTheDocument();
|
||||
|
||||
// No negative values with scientific notation
|
||||
fireEvent.change(input, { target: { value: '-5e5' } });
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText(countError)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { ConfigSubSection } from '@grafana/experimental';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { InlineField, Input, Select, Switch, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { SUGGESTIONS_LIMIT } from '../language_provider';
|
||||
import { QueryEditorMode } from '../querybuilder/shared/types';
|
||||
import { defaultPrometheusQueryOverlapWindow } from '../querycache/QueryCache';
|
||||
import { PromApplication, PrometheusCacheLevel, PromOptions } from '../types';
|
||||
@@ -52,7 +54,10 @@ export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/;
|
||||
// multiple duration input
|
||||
export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/;
|
||||
|
||||
export const NON_NEGATIVE_INTEGER_REGEX = /^(0|[1-9]\d*)(\.\d+)?(e\+?\d+)?$/; // non-negative integers, including scientific notation
|
||||
|
||||
const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s';
|
||||
export const countError = 'Value is not valid, you can use non-negative integers, including scientific notation';
|
||||
|
||||
export const PromSettings = (props: Props) => {
|
||||
const { options, onOptionsChange } = props;
|
||||
@@ -78,6 +83,14 @@ export const PromSettings = (props: Props) => {
|
||||
incrementalQueryOverlapWindow: '',
|
||||
});
|
||||
|
||||
type ValidCount = {
|
||||
codeModeMetricNamesSuggestionLimit: string;
|
||||
};
|
||||
|
||||
const [validCount, updateValidCount] = useState<ValidCount>({
|
||||
codeModeMetricNamesSuggestionLimit: '',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigSubSection title="Interval behaviour" className={styles.container}>
|
||||
@@ -301,6 +314,49 @@ export const PromSettings = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.featureToggles.prometheusCodeModeMetricNamesSearch && (
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<InlineField
|
||||
label="Metric names suggestion limit"
|
||||
labelWidth={PROM_CONFIG_LABEL_WIDTH}
|
||||
tooltip={
|
||||
<>
|
||||
The maximum number of metric names that may appear as autocomplete suggestions in the query
|
||||
editor's Code mode.
|
||||
</>
|
||||
}
|
||||
interactive={true}
|
||||
disabled={options.readOnly}
|
||||
>
|
||||
<>
|
||||
<Input
|
||||
className="width-20"
|
||||
value={options.jsonData.codeModeMetricNamesSuggestionLimit}
|
||||
onChange={onChangeHandler('codeModeMetricNamesSuggestionLimit', options, onOptionsChange)}
|
||||
spellCheck={false}
|
||||
placeholder={SUGGESTIONS_LIMIT.toString()}
|
||||
onBlur={(e) =>
|
||||
updateValidCount({
|
||||
...validCount,
|
||||
codeModeMetricNamesSuggestionLimit: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
data-testid={
|
||||
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
|
||||
}
|
||||
/>
|
||||
{validateInput(
|
||||
validCount.codeModeMetricNamesSuggestionLimit,
|
||||
NON_NEGATIVE_INTEGER_REGEX,
|
||||
countError
|
||||
)}
|
||||
</>
|
||||
</InlineField>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form max-width-30">
|
||||
<InlineField
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
|
||||
import { addLabelToQuery } from './add_label_to_query';
|
||||
import { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
|
||||
import PrometheusLanguageProvider from './language_provider';
|
||||
import PrometheusLanguageProvider, { SUGGESTIONS_LIMIT } from './language_provider';
|
||||
import {
|
||||
expandRecordingRules,
|
||||
getClientCacheDurationInMinutes,
|
||||
@@ -97,6 +97,7 @@ export class PrometheusDatasource
|
||||
exemplarsAvailable: boolean;
|
||||
cacheLevel: PrometheusCacheLevel;
|
||||
cache: QueryCache<PromQuery>;
|
||||
metricNamesAutocompleteSuggestionLimit: number;
|
||||
|
||||
constructor(
|
||||
instanceSettings: DataSourceInstanceSettings<PromOptions>,
|
||||
@@ -127,6 +128,8 @@ export class PrometheusDatasource
|
||||
this.variables = new PrometheusVariableSupport(this, this.templateSrv);
|
||||
this.exemplarsAvailable = true;
|
||||
this.cacheLevel = instanceSettings.jsonData.cacheLevel ?? PrometheusCacheLevel.Low;
|
||||
this.metricNamesAutocompleteSuggestionLimit =
|
||||
instanceSettings.jsonData.codeModeMetricNamesSuggestionLimit ?? SUGGESTIONS_LIMIT;
|
||||
|
||||
this.cache = new QueryCache({
|
||||
getTargetSignature: this.getPrometheusTargetSignature.bind(this),
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { render, screen, fireEvent, createEvent } from '@testing-library/react';
|
||||
import { cloneDeep, defaultsDeep } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { PluginMeta, PluginType } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT } from '../../components/monaco-query-field/monaco-completion-provider/data_provider';
|
||||
import { PromQueryEditorProps } from '../../components/types';
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import PromQlLanguageProvider from '../../language_provider';
|
||||
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
|
||||
import { PromQuery } from '../../types';
|
||||
import { QueryEditorMode } from '../shared/types';
|
||||
|
||||
import { PromQueryEditorSelector } from './PromQueryEditorSelector';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusCodeModeMetricNamesSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
// We need to mock this because it seems jest has problem importing monaco in tests
|
||||
jest.mock('../../components/monaco-query-field/MonacoQueryFieldWrapper', () => {
|
||||
return {
|
||||
MonacoQueryFieldWrapper: () => {
|
||||
return 'MonacoQueryFieldWrapper';
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../gcopypaste/app/core/store', () => {
|
||||
return {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
set() {},
|
||||
getObject(key: string, defaultValue: unknown) {
|
||||
return defaultValue;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
return {
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
reportInteraction: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const defaultQuery = {
|
||||
refId: 'A',
|
||||
expr: 'metric{label1="foo", label2="bar"}',
|
||||
};
|
||||
|
||||
const defaultMeta: PluginMeta = {
|
||||
id: '',
|
||||
name: '',
|
||||
type: PluginType.datasource,
|
||||
info: {
|
||||
author: {
|
||||
name: 'tester',
|
||||
},
|
||||
description: 'testing',
|
||||
links: [],
|
||||
logos: {
|
||||
large: '',
|
||||
small: '',
|
||||
},
|
||||
screenshots: [],
|
||||
updated: '',
|
||||
version: '',
|
||||
},
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
};
|
||||
|
||||
const getDefaultDatasource = (jsonDataOverrides = {}) =>
|
||||
new PrometheusDatasource(
|
||||
{
|
||||
id: 1,
|
||||
uid: 'myDataSourceUid',
|
||||
type: 'prometheus',
|
||||
name: 'prom-test',
|
||||
access: 'proxy',
|
||||
url: '',
|
||||
jsonData: jsonDataOverrides,
|
||||
meta: defaultMeta,
|
||||
readOnly: false,
|
||||
},
|
||||
undefined,
|
||||
new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider
|
||||
);
|
||||
|
||||
const defaultProps = {
|
||||
datasource: getDefaultDatasource(),
|
||||
query: defaultQuery,
|
||||
onRunQuery: () => {},
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
const autocompleteInfoSelector = selectors.components.DataSource.Prometheus.queryEditor.code.metricsCountInfo;
|
||||
|
||||
describe('PromQueryEditorSelector', () => {
|
||||
it('does not show autocomplete info when the code editor first displays', async () => {
|
||||
const { queryByTestId } = renderWithCodeMode();
|
||||
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
|
||||
expect(queryByTestId(autocompleteInfoSelector)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows autocomplete info when the expected event fires', async () => {
|
||||
const { findByTestId } = renderWithCodeMode();
|
||||
fireEvent(
|
||||
window,
|
||||
createEvent(
|
||||
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
|
||||
window,
|
||||
{
|
||||
detail: { limit: 100, datasourceUid: 'myDataSourceUid' },
|
||||
},
|
||||
{ EventType: 'CustomEvent' }
|
||||
)
|
||||
);
|
||||
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
|
||||
const autocompleteInfo = await findByTestId(autocompleteInfoSelector);
|
||||
expect(autocompleteInfo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show autocomplete info when the triggering event refers to a different data source', async () => {
|
||||
const { queryByTestId } = renderWithCodeMode();
|
||||
fireEvent(
|
||||
window,
|
||||
createEvent(
|
||||
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
|
||||
window,
|
||||
{
|
||||
detail: { limit: 100, datasourceUid: 'theWrongUid' },
|
||||
},
|
||||
{ EventType: 'CustomEvent' }
|
||||
)
|
||||
);
|
||||
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
|
||||
const autocompleteInfo = await queryByTestId(autocompleteInfoSelector);
|
||||
expect(autocompleteInfo).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithCodeMode() {
|
||||
return renderWithProps({ editorMode: QueryEditorMode.Code, expr: 'my_metric' });
|
||||
}
|
||||
|
||||
function renderWithProps(overrides?: Partial<PromQuery>, componentProps: Partial<PromQueryEditorProps> = {}) {
|
||||
const query = defaultsDeep(overrides ?? {}, cloneDeep(defaultQuery));
|
||||
const onChange = jest.fn();
|
||||
|
||||
const allProps = { ...defaultProps, ...componentProps };
|
||||
const stuff = render(<PromQueryEditorSelector {...allProps} query={query} onChange={onChange} />);
|
||||
return { onChange, ...stuff };
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { IconButton, Text, Stack } from '@grafana/ui';
|
||||
|
||||
import {
|
||||
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
|
||||
isSuggestionsIncompleteEvent,
|
||||
} from '../../components/monaco-query-field/monaco-completion-provider/data_provider';
|
||||
import { PromQueryEditorProps } from '../../components/types';
|
||||
import { QueryEditorMode } from '../shared/types';
|
||||
|
||||
interface Props {
|
||||
datasourceUid: PromQueryEditorProps['datasource']['uid'];
|
||||
editorMode: QueryEditorMode;
|
||||
}
|
||||
|
||||
export function PromQueryCodeEditorAutocompleteInfo(props: Readonly<Props>) {
|
||||
const [autocompleteLimit, setAutocompleteLimit] = useState('n');
|
||||
const [autocompleteLimitExceeded, setAutocompleteLimitExceeded] = useState(false);
|
||||
const handleSuggestionsIncompleteEvent = useCallback(
|
||||
(e: Event) => {
|
||||
if (!isSuggestionsIncompleteEvent(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.detail.datasourceUid === props.datasourceUid) {
|
||||
setAutocompleteLimitExceeded(true);
|
||||
setAutocompleteLimit(e.detail.limit.toString());
|
||||
}
|
||||
},
|
||||
[props.datasourceUid]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
addEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent);
|
||||
|
||||
return () => {
|
||||
removeEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent);
|
||||
};
|
||||
}, [handleSuggestionsIncompleteEvent]);
|
||||
|
||||
const showCodeModeAutocompleteDisclaimer = (): boolean => {
|
||||
return (
|
||||
Boolean(config.featureToggles.prometheusCodeModeMetricNamesSearch) &&
|
||||
props.editorMode === QueryEditorMode.Code &&
|
||||
autocompleteLimitExceeded
|
||||
);
|
||||
};
|
||||
|
||||
if (!showCodeModeAutocompleteDisclaimer()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsCountInfo}>
|
||||
<Stack direction="row" gap={1}>
|
||||
<Text color="secondary" element="p" italic={true}>
|
||||
Autocomplete suggestions limited
|
||||
</Text>
|
||||
<IconButton
|
||||
name="info-circle"
|
||||
tooltip={`The number of metric names exceeds the autocomplete limit. Only the ${autocompleteLimit}-most relevant metrics are displayed. You can adjust the threshold in the data source settings.`}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { changeEditorMode, getQueryWithDefaults } from '../state';
|
||||
import { PromQueryBuilderContainer } from './PromQueryBuilderContainer';
|
||||
import { PromQueryBuilderOptions } from './PromQueryBuilderOptions';
|
||||
import { PromQueryCodeEditor } from './PromQueryCodeEditor';
|
||||
import { PromQueryCodeEditorAutocompleteInfo } from './PromQueryCodeEditorAutocompleteInfo';
|
||||
|
||||
export const FORMAT_OPTIONS: Array<SelectableValue<PromQueryFormat>> = [
|
||||
{ label: 'Time series', value: 'time_series' },
|
||||
@@ -138,6 +139,7 @@ export const PromQueryEditorSelector = React.memo<Props>((props) => {
|
||||
Run queries
|
||||
</Button>
|
||||
)}
|
||||
<PromQueryCodeEditorAutocompleteInfo datasourceUid={props.datasource.uid} editorMode={editorMode} />
|
||||
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.editorToggle}>
|
||||
<QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} />
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface PromOptions extends DataSourceJsonData {
|
||||
disableRecordingRules?: boolean;
|
||||
sigV4Auth?: boolean;
|
||||
oauthPassThru?: boolean;
|
||||
codeModeMetricNamesSuggestionLimit?: number;
|
||||
}
|
||||
|
||||
export type ExemplarTraceIdDestination = {
|
||||
|
||||
@@ -859,6 +859,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityMetricsSquad,
|
||||
},
|
||||
{
|
||||
Name: "prometheusCodeModeMetricNamesSearch",
|
||||
Description: "Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names",
|
||||
FrontendOnly: true,
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityMetricsSquad,
|
||||
},
|
||||
{
|
||||
Name: "addFieldFromCalculationStatFunctions",
|
||||
Description: "Add cumulative and window functions to the add field from calculation transformation",
|
||||
|
||||
@@ -114,6 +114,7 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e
|
||||
panelTitleSearchInV1,experimental,@grafana/backend-platform,true,false,false
|
||||
managedPluginsInstall,GA,@grafana/plugins-platform-backend,false,false,false
|
||||
prometheusPromQAIL,experimental,@grafana/observability-metrics,false,false,true
|
||||
prometheusCodeModeMetricNamesSearch,experimental,@grafana/observability-metrics,false,false,true
|
||||
addFieldFromCalculationStatFunctions,preview,@grafana/dataviz-squad,false,false,true
|
||||
alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false
|
||||
|
||||
|
@@ -467,6 +467,10 @@ const (
|
||||
// Prometheus and AI/ML to assist users in creating a query
|
||||
FlagPrometheusPromQAIL = "prometheusPromQAIL"
|
||||
|
||||
// FlagPrometheusCodeModeMetricNamesSearch
|
||||
// Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names
|
||||
FlagPrometheusCodeModeMetricNamesSearch = "prometheusCodeModeMetricNamesSearch"
|
||||
|
||||
// FlagAddFieldFromCalculationStatFunctions
|
||||
// Add cumulative and window functions to the add field from calculation transformation
|
||||
FlagAddFieldFromCalculationStatFunctions = "addFieldFromCalculationStatFunctions"
|
||||
|
||||
@@ -2107,6 +2107,19 @@
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "prometheusCodeModeMetricNamesSearch",
|
||||
"resourceVersion": "1712241392690",
|
||||
"creationTimestamp": "2024-04-04T14:36:32Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables search for metric names in Code Mode, to improve performance when working with an enormous number of metric names",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-metrics",
|
||||
"frontend": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-35
@@ -17,6 +17,7 @@ import {
|
||||
import { Props } from './MonacoQueryFieldProps';
|
||||
import { getOverrideServices } from './getOverrideServices';
|
||||
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
|
||||
import { DataProvider } from './monaco-completion-provider/data_provider';
|
||||
|
||||
const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
|
||||
codeLens: false,
|
||||
@@ -145,41 +146,10 @@ const MonacoQueryField = (props: Props) => {
|
||||
editor.onDidFocusEditorText(() => {
|
||||
isEditorFocused.set(true);
|
||||
});
|
||||
|
||||
// we construct a DataProvider object
|
||||
const getHistory = () =>
|
||||
Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined));
|
||||
|
||||
const getAllMetricNames = () => {
|
||||
const { metrics, metricsMetadata } = lpRef.current;
|
||||
const result = metrics.map((m) => {
|
||||
const metaItem = metricsMetadata?.[m];
|
||||
return {
|
||||
name: m,
|
||||
help: metaItem?.help ?? '',
|
||||
type: metaItem?.type ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
return Promise.resolve(result);
|
||||
};
|
||||
|
||||
const getAllLabelNames = () => Promise.resolve(lpRef.current.getLabelKeys());
|
||||
|
||||
const getLabelValues = (labelName: string) => lpRef.current.getLabelValues(labelName);
|
||||
|
||||
const getSeriesValues = lpRef.current.getSeriesValues;
|
||||
|
||||
const getSeriesLabels = lpRef.current.getSeriesLabels;
|
||||
|
||||
const dataProvider = {
|
||||
getHistory,
|
||||
getAllMetricNames,
|
||||
getAllLabelNames,
|
||||
getLabelValues,
|
||||
getSeriesValues,
|
||||
getSeriesLabels,
|
||||
};
|
||||
const dataProvider = new DataProvider({
|
||||
historyProvider: historyRef.current,
|
||||
languageProvider: lpRef.current,
|
||||
});
|
||||
const completionProvider = getCompletionProvider(monaco, dataProvider);
|
||||
|
||||
// completion-providers in monaco are not registered directly to editor-instances,
|
||||
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { SUGGESTIONS_LIMIT } from '../../../language_provider';
|
||||
import { FUNCTIONS } from '../../../promql';
|
||||
|
||||
import { getCompletions } from './completions';
|
||||
import { DataProvider, DataProviderParams } from './data_provider';
|
||||
import type { Situation } from './situation';
|
||||
|
||||
const history: string[] = ['previous_metric_name_1', 'previous_metric_name_2', 'previous_metric_name_3'];
|
||||
const dataProviderSettings = {
|
||||
languageProvider: {
|
||||
datasource: {
|
||||
metricNamesAutocompleteSuggestionLimit: SUGGESTIONS_LIMIT,
|
||||
},
|
||||
getLabelKeys: jest.fn(),
|
||||
getLabelValues: jest.fn(),
|
||||
getSeriesLabels: jest.fn(),
|
||||
getSeriesValues: jest.fn(),
|
||||
metrics: [],
|
||||
metricsMetadata: {},
|
||||
},
|
||||
historyProvider: history.map((expr, idx) => ({ query: { expr, refId: 'some-ref' }, ts: idx })),
|
||||
} as unknown as DataProviderParams;
|
||||
let dataProvider = new DataProvider(dataProviderSettings);
|
||||
const metrics = {
|
||||
beyondLimit: Array.from(Array(SUGGESTIONS_LIMIT + 1), (_, i) => `metric_name_${i}`),
|
||||
get atLimit() {
|
||||
return this.beyondLimit.slice(0, SUGGESTIONS_LIMIT - 1);
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dataProvider = new DataProvider(dataProviderSettings);
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusCodeModeMetricNamesSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
type MetricNameSituation = Extract<Situation['type'], 'AT_ROOT' | 'EMPTY' | 'IN_FUNCTION'>;
|
||||
const metricNameCompletionSituations = ['AT_ROOT', 'IN_FUNCTION', 'EMPTY'] as MetricNameSituation[];
|
||||
|
||||
function getSuggestionCountForSituation(situationType: MetricNameSituation, metricsCount: number): number {
|
||||
const limitedMetricNamesCount = metricsCount < SUGGESTIONS_LIMIT ? metricsCount : SUGGESTIONS_LIMIT;
|
||||
let suggestionsCount = limitedMetricNamesCount + FUNCTIONS.length;
|
||||
|
||||
if (situationType === 'EMPTY') {
|
||||
suggestionsCount += history.length;
|
||||
}
|
||||
|
||||
return suggestionsCount;
|
||||
}
|
||||
|
||||
describe.each(metricNameCompletionSituations)('metric name completions in situation %s', (situationType) => {
|
||||
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);
|
||||
const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.atLimit.length);
|
||||
const situation: Situation = {
|
||||
type: situationType,
|
||||
};
|
||||
|
||||
// No text input
|
||||
dataProvider.monacoSettings.setInputInRange('');
|
||||
let completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions).toHaveLength(expectedCompletionsCount);
|
||||
|
||||
// With text input (use fuzzy search)
|
||||
dataProvider.monacoSettings.setInputInRange('name_1');
|
||||
completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount);
|
||||
});
|
||||
|
||||
it('should limit completions for metric names when the number of metric names is greater than the limit', async () => {
|
||||
const situation: Situation = {
|
||||
type: situationType,
|
||||
};
|
||||
const expectedCompletionsCount = getSuggestionCountForSituation(situationType, metrics.beyondLimit.length);
|
||||
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValue(metrics.beyondLimit);
|
||||
|
||||
// No text input
|
||||
dataProvider.monacoSettings.setInputInRange('');
|
||||
let completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions).toHaveLength(expectedCompletionsCount);
|
||||
|
||||
// With text input (use fuzzy search)
|
||||
dataProvider.monacoSettings.setInputInRange('name_1');
|
||||
completions = await getCompletions(situation, dataProvider);
|
||||
expect(completions?.length).toBeLessThanOrEqual(expectedCompletionsCount);
|
||||
});
|
||||
|
||||
it('should enable autocomplete suggestions update when the number of metric names is greater than the limit', async () => {
|
||||
const situation: Situation = {
|
||||
type: situationType,
|
||||
};
|
||||
|
||||
// Do not cross the metrics names threshold
|
||||
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.atLimit);
|
||||
dataProvider.monacoSettings.setInputInRange('name_1');
|
||||
await getCompletions(situation, dataProvider);
|
||||
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(false);
|
||||
|
||||
// Cross the metric names threshold, without text input
|
||||
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit);
|
||||
dataProvider.monacoSettings.setInputInRange('');
|
||||
await getCompletions(situation, dataProvider);
|
||||
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true);
|
||||
|
||||
// Cross the metric names threshold, with text input
|
||||
jest.spyOn(dataProvider, 'getAllMetricNames').mockReturnValueOnce(metrics.beyondLimit);
|
||||
dataProvider.monacoSettings.setInputInRange('name_1');
|
||||
await getCompletions(situation, dataProvider);
|
||||
expect(dataProvider.monacoSettings.suggestionsIncomplete).toBe(true);
|
||||
});
|
||||
});
|
||||
+36
-26
@@ -1,6 +1,11 @@
|
||||
import UFuzzy from '@leeoniya/ufuzzy';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { escapeLabelValueInExactSelector } from '../../../language_utils';
|
||||
import { FUNCTIONS } from '../../../promql';
|
||||
|
||||
import { DataProvider } from './data_provider';
|
||||
import type { Situation, Label } 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
|
||||
@@ -16,26 +21,31 @@ type Completion = {
|
||||
triggerOnInsert?: boolean;
|
||||
};
|
||||
|
||||
type Metric = {
|
||||
name: string;
|
||||
help: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type DataProvider = {
|
||||
getHistory: () => Promise<string[]>;
|
||||
getAllMetricNames: () => Promise<Metric[]>;
|
||||
getAllLabelNames: () => Promise<string[]>;
|
||||
getLabelValues: (labelName: string) => Promise<string[]>;
|
||||
getSeriesValues: (name: string, match: string) => Promise<string[]>;
|
||||
getSeriesLabels: (selector: string, otherLabels: Label[]) => Promise<string[]>;
|
||||
};
|
||||
const metricNamesSearchClient = new UFuzzy({ intraMode: 1 });
|
||||
|
||||
// we order items like: history, functions, metrics
|
||||
function getAllMetricNamesCompletions(dataProvider: DataProvider): Completion[] {
|
||||
let metricNames = dataProvider.getAllMetricNames();
|
||||
|
||||
async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
|
||||
const metrics = await dataProvider.getAllMetricNames();
|
||||
return metrics.map((metric) => ({
|
||||
if (
|
||||
config.featureToggles.prometheusCodeModeMetricNamesSearch &&
|
||||
metricNames.length > dataProvider.metricNamesSuggestionLimit
|
||||
) {
|
||||
const { monacoSettings } = dataProvider;
|
||||
monacoSettings.enableAutocompleteSuggestionsUpdate();
|
||||
|
||||
if (monacoSettings.inputInRange) {
|
||||
metricNames =
|
||||
metricNamesSearchClient
|
||||
.filter(metricNames, monacoSettings.inputInRange)
|
||||
?.slice(0, dataProvider.metricNamesSuggestionLimit)
|
||||
.map((idx) => metricNames[idx]) ?? [];
|
||||
} else {
|
||||
metricNames = metricNames.slice(0, dataProvider.metricNamesSuggestionLimit);
|
||||
}
|
||||
}
|
||||
|
||||
return dataProvider.metricNamesToMetrics(metricNames).map((metric) => ({
|
||||
type: 'METRIC_NAME',
|
||||
label: metric.name,
|
||||
insertText: metric.name,
|
||||
@@ -53,7 +63,7 @@ const FUNCTION_COMPLETIONS: Completion[] = FUNCTIONS.map((f) => ({
|
||||
}));
|
||||
|
||||
async function getAllFunctionsAndMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
|
||||
const metricNames = await getAllMetricNamesCompletions(dataProvider);
|
||||
const metricNames = getAllMetricNamesCompletions(dataProvider);
|
||||
return [...FUNCTION_COMPLETIONS, ...metricNames];
|
||||
}
|
||||
|
||||
@@ -73,10 +83,10 @@ const DURATION_COMPLETIONS: Completion[] = [
|
||||
insertText: text,
|
||||
}));
|
||||
|
||||
async function getAllHistoryCompletions(dataProvider: DataProvider): Promise<Completion[]> {
|
||||
function getAllHistoryCompletions(dataProvider: DataProvider): Completion[] {
|
||||
// function getAllHistoryCompletions(queryHistory: PromHistoryItem[]): Completion[] {
|
||||
// NOTE: the typescript types are wrong. historyItem.query.expr can be undefined
|
||||
const allHistory = await dataProvider.getHistory();
|
||||
const allHistory = dataProvider.getHistory();
|
||||
// FIXME: find a better history-limit
|
||||
return allHistory.slice(0, 10).map((expr) => ({
|
||||
type: 'HISTORY',
|
||||
@@ -107,7 +117,7 @@ async function getLabelNames(
|
||||
): Promise<string[]> {
|
||||
if (metric === undefined && otherLabels.length === 0) {
|
||||
// if there is no filtering, we have to use a special endpoint
|
||||
return dataProvider.getAllLabelNames();
|
||||
return Promise.resolve(dataProvider.getAllLabelNames());
|
||||
} else {
|
||||
const selector = makeSelector(metric, otherLabels);
|
||||
return await dataProvider.getSeriesLabels(selector, otherLabels);
|
||||
@@ -175,19 +185,19 @@ async function getLabelValuesForMetricCompletions(
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
|
||||
export function getCompletions(situation: Situation, dataProvider: DataProvider): Promise<Completion[]> {
|
||||
switch (situation.type) {
|
||||
case 'IN_DURATION':
|
||||
return DURATION_COMPLETIONS;
|
||||
return Promise.resolve(DURATION_COMPLETIONS);
|
||||
case 'IN_FUNCTION':
|
||||
return getAllFunctionsAndMetricNamesCompletions(dataProvider);
|
||||
case 'AT_ROOT': {
|
||||
return getAllFunctionsAndMetricNamesCompletions(dataProvider);
|
||||
}
|
||||
case 'EMPTY': {
|
||||
const metricNames = await getAllMetricNamesCompletions(dataProvider);
|
||||
const historyCompletions = await getAllHistoryCompletions(dataProvider);
|
||||
return [...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames];
|
||||
const metricNames = getAllMetricNamesCompletions(dataProvider);
|
||||
const historyCompletions = getAllHistoryCompletions(dataProvider);
|
||||
return Promise.resolve([...historyCompletions, ...FUNCTION_COMPLETIONS, ...metricNames]);
|
||||
}
|
||||
case 'IN_LABEL_SELECTOR_NO_LABEL_NAME':
|
||||
return getLabelNamesForSelectorCompletions(situation.metricName, situation.otherLabels, dataProvider);
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { HistoryItem } from '@grafana/data';
|
||||
import type { Monaco } from '@grafana/ui'; // used in TSDoc `@link` below
|
||||
|
||||
import PromQlLanguageProvider from '../../../language_provider';
|
||||
import { PromQuery } from '../../../types';
|
||||
|
||||
export const CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT = 'codeModeSuggestionsIncomplete';
|
||||
|
||||
export type SuggestionsIncompleteEvent = CustomEvent<{
|
||||
limit: number;
|
||||
datasourceUid: string;
|
||||
}>;
|
||||
|
||||
export function isSuggestionsIncompleteEvent(e: Event): e is SuggestionsIncompleteEvent {
|
||||
return (
|
||||
e.type === CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT &&
|
||||
'detail' in e &&
|
||||
typeof e.detail === 'object' &&
|
||||
e.detail !== null &&
|
||||
'limit' in e.detail &&
|
||||
'datasourceUid' in e.detail
|
||||
);
|
||||
}
|
||||
|
||||
interface Metric {
|
||||
name: string;
|
||||
help: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface DataProviderParams {
|
||||
languageProvider: PromQlLanguageProvider;
|
||||
historyProvider: Array<HistoryItem<PromQuery>>;
|
||||
}
|
||||
|
||||
export class DataProvider {
|
||||
readonly languageProvider: PromQlLanguageProvider;
|
||||
readonly historyProvider: Array<HistoryItem<PromQuery>>;
|
||||
readonly getSeriesLabels: typeof this.languageProvider.getSeriesLabels;
|
||||
readonly getSeriesValues: typeof this.languageProvider.getSeriesValues;
|
||||
readonly getAllLabelNames: typeof this.languageProvider.getLabelKeys;
|
||||
readonly getLabelValues: typeof this.languageProvider.getLabelValues;
|
||||
readonly metricNamesSuggestionLimit: number;
|
||||
/**
|
||||
* The text that's been typed so far within the current {@link Monaco.Range | Range}.
|
||||
*
|
||||
* @remarks
|
||||
* This is useful with fuzzy searching items to provide as Monaco autocomplete suggestions.
|
||||
*/
|
||||
private inputInRange: string;
|
||||
private suggestionsIncomplete: boolean;
|
||||
|
||||
constructor(params: DataProviderParams) {
|
||||
this.languageProvider = params.languageProvider;
|
||||
this.historyProvider = params.historyProvider;
|
||||
this.inputInRange = '';
|
||||
this.metricNamesSuggestionLimit = this.languageProvider.datasource.metricNamesAutocompleteSuggestionLimit;
|
||||
this.suggestionsIncomplete = false;
|
||||
this.getSeriesLabels = this.languageProvider.getSeriesLabels.bind(this.languageProvider);
|
||||
this.getSeriesValues = this.languageProvider.getSeriesValues.bind(this.languageProvider);
|
||||
this.getAllLabelNames = this.languageProvider.getLabelKeys.bind(this.languageProvider);
|
||||
this.getLabelValues = this.languageProvider.getLabelValues.bind(this.languageProvider);
|
||||
}
|
||||
|
||||
getHistory(): string[] {
|
||||
return this.historyProvider.map((h) => h.query.expr).filter(Boolean);
|
||||
}
|
||||
|
||||
getAllMetricNames(): string[] {
|
||||
return this.languageProvider.metrics;
|
||||
}
|
||||
|
||||
metricNamesToMetrics(metricNames: string[]): Metric[] {
|
||||
const { metricsMetadata } = this.languageProvider;
|
||||
const result: Metric[] = metricNames.map((m) => {
|
||||
const metaItem = metricsMetadata?.[m];
|
||||
return {
|
||||
name: m,
|
||||
help: metaItem?.help ?? '',
|
||||
type: metaItem?.type ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private enableAutocompleteSuggestionsUpdate(): void {
|
||||
this.suggestionsIncomplete = true;
|
||||
dispatchEvent(
|
||||
new CustomEvent(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, {
|
||||
detail: { limit: this.metricNamesSuggestionLimit, datasourceUid: this.languageProvider.datasource.uid },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private setInputInRange(textInput: string): void {
|
||||
this.inputInRange = textInput;
|
||||
}
|
||||
|
||||
get monacoSettings() {
|
||||
return {
|
||||
/**
|
||||
* Enable autocomplete suggestions update on every input change.
|
||||
*
|
||||
* @remarks
|
||||
* If fuzzy search is used in `getCompletions` to trim down results to improve performance,
|
||||
* we need to instruct Monaco to update the completions on every input change, so that the
|
||||
* completions reflect the current input.
|
||||
*/
|
||||
enableAutocompleteSuggestionsUpdate: this.enableAutocompleteSuggestionsUpdate.bind(this),
|
||||
inputInRange: this.inputInRange,
|
||||
setInputInRange: this.setInputInRange.bind(this),
|
||||
suggestionsIncomplete: this.suggestionsIncomplete,
|
||||
};
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -1,6 +1,7 @@
|
||||
import type { Monaco, monacoTypes } from '@grafana/ui';
|
||||
|
||||
import { getCompletions, DataProvider, CompletionType } from './completions';
|
||||
import { CompletionType, getCompletions } from './completions';
|
||||
import { DataProvider } from './data_provider';
|
||||
import { getSituation } from './situation';
|
||||
import { NeverCaseError } from './util';
|
||||
|
||||
@@ -69,6 +70,7 @@ export function getCompletionProvider(
|
||||
column: position.column,
|
||||
lineNumber: position.lineNumber,
|
||||
};
|
||||
dataProvider.monacoSettings.setInputInRange(model.getValueInRange(range));
|
||||
|
||||
// Check to see if the browser supports window.getSelection()
|
||||
if (window.getSelection) {
|
||||
@@ -82,6 +84,7 @@ export function getCompletionProvider(
|
||||
const offset = model.getOffsetAt(positionClone);
|
||||
const situation = getSituation(model.getValue(), offset);
|
||||
const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]);
|
||||
|
||||
return completionsPromise.then((items) => {
|
||||
// monaco by-default alphabetically orders the items.
|
||||
// to stop it, we use a number-as-string sortkey,
|
||||
@@ -102,7 +105,7 @@ export function getCompletionProvider(
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
return { suggestions };
|
||||
return { suggestions, incomplete: dataProvider.monacoSettings.suggestionsIncomplete };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import React, { SyntheticEvent } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { configureStore } from '../../../../store/configureStore';
|
||||
|
||||
import { getValueFromEventItem, PromSettings } from './PromSettings';
|
||||
import { countError, getValueFromEventItem, PromSettings } from './PromSettings';
|
||||
import { createDefaultConfigOptions } from './mocks';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusCodeModeMetricNamesSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('PromSettings', () => {
|
||||
describe('getValueFromEventItem', () => {
|
||||
describe('when called with undefined', () => {
|
||||
@@ -79,5 +87,56 @@ describe('PromSettings', () => {
|
||||
);
|
||||
expect(screen.getByText('GET')).toBeInTheDocument();
|
||||
});
|
||||
it('should show a valid metric name count if codeModeMetricNamesSuggestionLimit is configured correctly', () => {
|
||||
const options = defaultProps;
|
||||
const store = configureStore();
|
||||
|
||||
const { getByTestId, queryByText } = render(
|
||||
<Provider store={store}>
|
||||
<PromSettings onOptionsChange={() => {}} options={options} />
|
||||
</Provider>
|
||||
);
|
||||
const input = getByTestId(
|
||||
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
|
||||
);
|
||||
|
||||
// Non-negative integer
|
||||
fireEvent.change(input, { target: { value: '3000' } });
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText(countError)).not.toBeInTheDocument();
|
||||
|
||||
// Non-negative integer with scientific notation
|
||||
fireEvent.change(input, { target: { value: '1e5' } });
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText(countError)).not.toBeInTheDocument();
|
||||
|
||||
// Non-negative integer with decimal scientific notation
|
||||
fireEvent.change(input, { target: { value: '1.4e4' } });
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText(countError)).not.toBeInTheDocument();
|
||||
});
|
||||
it('should show the expected error when an invalid value is provided for codeModeMetricNamesSuggestionLimit', () => {
|
||||
const options = defaultProps;
|
||||
const store = configureStore();
|
||||
|
||||
const { getByTestId, queryByText } = render(
|
||||
<Provider store={store}>
|
||||
<PromSettings onOptionsChange={() => {}} options={options} />
|
||||
</Provider>
|
||||
);
|
||||
const input = getByTestId(
|
||||
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
|
||||
);
|
||||
|
||||
// No negative values
|
||||
fireEvent.change(input, { target: { value: '-50' } });
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText(countError)).toBeInTheDocument();
|
||||
|
||||
// No negative values with scientific notation
|
||||
fireEvent.change(input, { target: { value: '-5e5' } });
|
||||
fireEvent.blur(input);
|
||||
expect(queryByText(countError)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { ConfigSubSection } from '@grafana/experimental';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { getBackendSrv } from '@grafana/runtime/src';
|
||||
import { InlineField, Input, Select, Switch, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { useUpdateDatasource } from '../../../../features/datasources/state';
|
||||
import { SUGGESTIONS_LIMIT } from '../language_provider';
|
||||
import { QueryEditorMode } from '../querybuilder/shared/types';
|
||||
import { defaultPrometheusQueryOverlapWindow } from '../querycache/QueryCache';
|
||||
import { PromApplication, PromBuildInfoResponse, PrometheusCacheLevel, PromOptions } from '../types';
|
||||
@@ -56,7 +58,10 @@ export const DURATION_REGEX = /^$|^\d+(ms|[Mwdhmsy])$/;
|
||||
// multiple duration input
|
||||
export const MULTIPLE_DURATION_REGEX = /(\d+)(.+)/;
|
||||
|
||||
export const NON_NEGATIVE_INTEGER_REGEX = /^(0|[1-9]\d*)(\.\d+)?(e\+?\d+)?$/; // non-negative integers, including scientific notation
|
||||
|
||||
const durationError = 'Value is not valid, you can use number with time unit specifier: y, M, w, d, h, m, s';
|
||||
export const countError = 'Value is not valid, you can use non-negative integers, including scientific notation';
|
||||
/**
|
||||
* Returns the closest version to what the user provided that we have in our PromFlavorVersions for the currently selected flavor
|
||||
* Bugs: It will only reject versions that are a major release apart, so Mimir 2.x might get selected for Prometheus 2.8 if the user selects an incorrect flavor
|
||||
@@ -169,6 +174,14 @@ export const PromSettings = (props: Props) => {
|
||||
incrementalQueryOverlapWindow: '',
|
||||
});
|
||||
|
||||
type ValidCount = {
|
||||
codeModeMetricNamesSuggestionLimit: string;
|
||||
};
|
||||
|
||||
const [validCount, updateValidCount] = useState<ValidCount>({
|
||||
codeModeMetricNamesSuggestionLimit: '',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigSubSection title="Interval behaviour" className={styles.container}>
|
||||
@@ -405,6 +418,49 @@ export const PromSettings = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.featureToggles.prometheusCodeModeMetricNamesSearch && (
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<InlineField
|
||||
label="Metric names suggestion limit"
|
||||
labelWidth={PROM_CONFIG_LABEL_WIDTH}
|
||||
tooltip={
|
||||
<>
|
||||
The maximum number of metric names that may appear as autocomplete suggestions in the query
|
||||
editor's Code mode.
|
||||
</>
|
||||
}
|
||||
interactive={true}
|
||||
disabled={options.readOnly}
|
||||
>
|
||||
<>
|
||||
<Input
|
||||
className="width-20"
|
||||
value={options.jsonData.codeModeMetricNamesSuggestionLimit}
|
||||
onChange={onChangeHandler('codeModeMetricNamesSuggestionLimit', options, onOptionsChange)}
|
||||
spellCheck={false}
|
||||
placeholder={SUGGESTIONS_LIMIT.toString()}
|
||||
onBlur={(e) =>
|
||||
updateValidCount({
|
||||
...validCount,
|
||||
codeModeMetricNamesSuggestionLimit: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
data-testid={
|
||||
selectors.components.DataSource.Prometheus.configPage.codeModeMetricNamesSuggestionLimit
|
||||
}
|
||||
/>
|
||||
{validateInput(
|
||||
validCount.codeModeMetricNamesSuggestionLimit,
|
||||
NON_NEGATIVE_INTEGER_REGEX,
|
||||
countError
|
||||
)}
|
||||
</>
|
||||
</InlineField>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form max-width-30">
|
||||
<InlineField
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
|
||||
import { addLabelToQuery } from './add_label_to_query';
|
||||
import { AnnotationQueryEditor } from './components/AnnotationQueryEditor';
|
||||
import PrometheusLanguageProvider from './language_provider';
|
||||
import PrometheusLanguageProvider, { SUGGESTIONS_LIMIT } from './language_provider';
|
||||
import {
|
||||
expandRecordingRules,
|
||||
getClientCacheDurationInMinutes,
|
||||
@@ -97,6 +97,7 @@ export class PrometheusDatasource
|
||||
exemplarsAvailable: boolean;
|
||||
cacheLevel: PrometheusCacheLevel;
|
||||
cache: QueryCache<PromQuery>;
|
||||
metricNamesAutocompleteSuggestionLimit: number;
|
||||
|
||||
constructor(
|
||||
instanceSettings: DataSourceInstanceSettings<PromOptions>,
|
||||
@@ -127,6 +128,8 @@ export class PrometheusDatasource
|
||||
this.variables = new PrometheusVariableSupport(this, this.templateSrv);
|
||||
this.exemplarsAvailable = true;
|
||||
this.cacheLevel = instanceSettings.jsonData.cacheLevel ?? PrometheusCacheLevel.Low;
|
||||
this.metricNamesAutocompleteSuggestionLimit =
|
||||
instanceSettings.jsonData.codeModeMetricNamesSuggestionLimit ?? SUGGESTIONS_LIMIT;
|
||||
|
||||
this.cache = new QueryCache({
|
||||
getTargetSignature: this.getPrometheusTargetSignature.bind(this),
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { render, screen, fireEvent, createEvent } from '@testing-library/react';
|
||||
import { cloneDeep, defaultsDeep } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { PluginMeta, PluginType } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT } from '../../components/monaco-query-field/monaco-completion-provider/data_provider';
|
||||
import { PromQueryEditorProps } from '../../components/types';
|
||||
import { PrometheusDatasource } from '../../datasource';
|
||||
import PromQlLanguageProvider from '../../language_provider';
|
||||
import { EmptyLanguageProviderMock } from '../../language_provider.mock';
|
||||
import { PromQuery } from '../../types';
|
||||
import { QueryEditorMode } from '../shared/types';
|
||||
|
||||
import { PromQueryEditorSelector } from './PromQueryEditorSelector';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.replaceProperty(config, 'featureToggles', {
|
||||
prometheusCodeModeMetricNamesSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
// We need to mock this because it seems jest has problem importing monaco in tests
|
||||
jest.mock('../../components/monaco-query-field/MonacoQueryFieldWrapper', () => {
|
||||
return {
|
||||
MonacoQueryFieldWrapper: () => {
|
||||
return 'MonacoQueryFieldWrapper';
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('app/core/store', () => {
|
||||
return {
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
set() {},
|
||||
getObject(key: string, defaultValue: unknown) {
|
||||
return defaultValue;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@grafana/runtime', () => {
|
||||
return {
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
reportInteraction: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const defaultQuery = {
|
||||
refId: 'A',
|
||||
expr: 'metric{label1="foo", label2="bar"}',
|
||||
};
|
||||
|
||||
const defaultMeta: PluginMeta = {
|
||||
id: '',
|
||||
name: '',
|
||||
type: PluginType.datasource,
|
||||
info: {
|
||||
author: {
|
||||
name: 'tester',
|
||||
},
|
||||
description: 'testing',
|
||||
links: [],
|
||||
logos: {
|
||||
large: '',
|
||||
small: '',
|
||||
},
|
||||
screenshots: [],
|
||||
updated: '',
|
||||
version: '',
|
||||
},
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
};
|
||||
|
||||
const getDefaultDatasource = (jsonDataOverrides = {}) =>
|
||||
new PrometheusDatasource(
|
||||
{
|
||||
id: 1,
|
||||
uid: 'myDataSourceUid',
|
||||
type: 'prometheus',
|
||||
name: 'prom-test',
|
||||
access: 'proxy',
|
||||
url: '',
|
||||
jsonData: jsonDataOverrides,
|
||||
meta: defaultMeta,
|
||||
readOnly: false,
|
||||
},
|
||||
undefined,
|
||||
new EmptyLanguageProviderMock() as unknown as PromQlLanguageProvider
|
||||
);
|
||||
|
||||
const defaultProps = {
|
||||
datasource: getDefaultDatasource(),
|
||||
query: defaultQuery,
|
||||
onRunQuery: () => {},
|
||||
onChange: () => {},
|
||||
};
|
||||
|
||||
const autocompleteInfoSelector = selectors.components.DataSource.Prometheus.queryEditor.code.metricsCountInfo;
|
||||
|
||||
describe('PromQueryEditorSelector', () => {
|
||||
it('does not show autocomplete info when the code editor first displays', async () => {
|
||||
const { queryByTestId } = renderWithCodeMode();
|
||||
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
|
||||
expect(queryByTestId(autocompleteInfoSelector)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows autocomplete info when the expected event fires', async () => {
|
||||
const { findByTestId } = renderWithCodeMode();
|
||||
fireEvent(
|
||||
window,
|
||||
createEvent(
|
||||
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
|
||||
window,
|
||||
{
|
||||
detail: { limit: 100, datasourceUid: 'myDataSourceUid' },
|
||||
},
|
||||
{ EventType: 'CustomEvent' }
|
||||
)
|
||||
);
|
||||
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
|
||||
const autocompleteInfo = await findByTestId(autocompleteInfoSelector);
|
||||
expect(autocompleteInfo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show autocomplete info when the triggering event refers to a different data source', async () => {
|
||||
const { queryByTestId } = renderWithCodeMode();
|
||||
fireEvent(
|
||||
window,
|
||||
createEvent(
|
||||
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
|
||||
window,
|
||||
{
|
||||
detail: { limit: 100, datasourceUid: 'theWrongUid' },
|
||||
},
|
||||
{ EventType: 'CustomEvent' }
|
||||
)
|
||||
);
|
||||
expect(await screen.findByText('MonacoQueryFieldWrapper')).toBeInTheDocument();
|
||||
const autocompleteInfo = await queryByTestId(autocompleteInfoSelector);
|
||||
expect(autocompleteInfo).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function renderWithCodeMode() {
|
||||
return renderWithProps({ editorMode: QueryEditorMode.Code, expr: 'my_metric' });
|
||||
}
|
||||
|
||||
function renderWithProps(overrides?: Partial<PromQuery>, componentProps: Partial<PromQueryEditorProps> = {}) {
|
||||
const query = defaultsDeep(overrides ?? {}, cloneDeep(defaultQuery));
|
||||
const onChange = jest.fn();
|
||||
|
||||
const allProps = { ...defaultProps, ...componentProps };
|
||||
const stuff = render(<PromQueryEditorSelector {...allProps} query={query} onChange={onChange} />);
|
||||
return { onChange, ...stuff };
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { IconButton, Text, Stack } from '@grafana/ui';
|
||||
|
||||
import {
|
||||
CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT,
|
||||
isSuggestionsIncompleteEvent,
|
||||
} from '../../components/monaco-query-field/monaco-completion-provider/data_provider';
|
||||
import { PromQueryEditorProps } from '../../components/types';
|
||||
import { QueryEditorMode } from '../shared/types';
|
||||
|
||||
interface Props {
|
||||
datasourceUid: PromQueryEditorProps['datasource']['uid'];
|
||||
editorMode: QueryEditorMode;
|
||||
}
|
||||
|
||||
export function PromQueryCodeEditorAutocompleteInfo(props: Readonly<Props>) {
|
||||
const [autocompleteLimit, setAutocompleteLimit] = useState('n');
|
||||
const [autocompleteLimitExceeded, setAutocompleteLimitExceeded] = useState(false);
|
||||
const handleSuggestionsIncompleteEvent = useCallback(
|
||||
(e: Event) => {
|
||||
if (!isSuggestionsIncompleteEvent(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.detail.datasourceUid === props.datasourceUid) {
|
||||
setAutocompleteLimitExceeded(true);
|
||||
setAutocompleteLimit(e.detail.limit.toString());
|
||||
}
|
||||
},
|
||||
[props.datasourceUid]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
addEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent);
|
||||
|
||||
return () => {
|
||||
removeEventListener(CODE_MODE_SUGGESTIONS_INCOMPLETE_EVENT, handleSuggestionsIncompleteEvent);
|
||||
};
|
||||
}, [handleSuggestionsIncompleteEvent]);
|
||||
|
||||
const showCodeModeAutocompleteDisclaimer = (): boolean => {
|
||||
return (
|
||||
Boolean(config.featureToggles.prometheusCodeModeMetricNamesSearch) &&
|
||||
props.editorMode === QueryEditorMode.Code &&
|
||||
autocompleteLimitExceeded
|
||||
);
|
||||
};
|
||||
|
||||
if (!showCodeModeAutocompleteDisclaimer()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsCountInfo}>
|
||||
<Stack direction="row" gap={1}>
|
||||
<Text color="secondary" element="p" italic={true}>
|
||||
Autocomplete suggestions limited
|
||||
</Text>
|
||||
<IconButton
|
||||
name="info-circle"
|
||||
tooltip={`The number of metric names exceeds the autocomplete limit. Only the ${autocompleteLimit}-most relevant metrics are displayed. You can adjust the threshold in the data source settings.`}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
@@ -21,6 +21,7 @@ import { changeEditorMode, getQueryWithDefaults } from '../state';
|
||||
import { PromQueryBuilderContainer } from './PromQueryBuilderContainer';
|
||||
import { PromQueryBuilderOptions } from './PromQueryBuilderOptions';
|
||||
import { PromQueryCodeEditor } from './PromQueryCodeEditor';
|
||||
import { PromQueryCodeEditorAutocompleteInfo } from './PromQueryCodeEditorAutocompleteInfo';
|
||||
|
||||
export const FORMAT_OPTIONS: Array<SelectableValue<PromQueryFormat>> = [
|
||||
{ label: 'Time series', value: 'time_series' },
|
||||
@@ -138,6 +139,7 @@ export const PromQueryEditorSelector = React.memo<Props>((props) => {
|
||||
Run queries
|
||||
</Button>
|
||||
)}
|
||||
<PromQueryCodeEditorAutocompleteInfo datasourceUid={props.datasource.uid} editorMode={editorMode} />
|
||||
<div data-testid={selectors.components.DataSource.Prometheus.queryEditor.editorToggle}>
|
||||
<QueryEditorModeToggle mode={editorMode} onChange={onEditorModeChange} />
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface PromOptions extends DataSourceJsonData {
|
||||
disableRecordingRules?: boolean;
|
||||
sigV4Auth?: boolean;
|
||||
oauthPassThru?: boolean;
|
||||
codeModeMetricNamesSuggestionLimit?: number;
|
||||
}
|
||||
|
||||
export type ExemplarTraceIdDestination = {
|
||||
|
||||
Reference in New Issue
Block a user