mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 16:15:42 -06:00
Prometheus: monaco-based query-editor-field, first step (#37251)
* prometheus: add monaco-based query-field for now it is behind a feature-flag * added new trigger character * better separate grafana-specifc and prom-specific code * better styling * more styling * more styling * simpler monaco-import * better imports * simplified code * simplified type imports * refactor: group completion-provider files together * renamed type * simplify type-import Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com> * handle no-metric-name autocompletes * updated comment Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
This commit is contained in:
parent
8d3b31ff23
commit
a5d11a3bef
@ -272,12 +272,16 @@
|
||||
"jquery": "3.5.1",
|
||||
"json-source-map": "0.6.1",
|
||||
"jsurl": "^0.1.5",
|
||||
"lezer": "0.13.5",
|
||||
"lezer-promql": "0.20.0",
|
||||
"lezer-tree": "0.13.2",
|
||||
"lodash": "4.17.21",
|
||||
"logfmt": "^1.3.2",
|
||||
"lru-cache": "^5.1.1",
|
||||
"memoize-one": "5.1.1",
|
||||
"moment": "2.29.1",
|
||||
"moment-timezone": "0.5.33",
|
||||
"monaco-promql": "^1.7.2",
|
||||
"mousetrap": "1.6.5",
|
||||
"mousetrap-global-bind": "1.1.0",
|
||||
"ol": "^6.5.0",
|
||||
|
@ -50,6 +50,7 @@ export interface FeatureToggles {
|
||||
accesscontrol: boolean;
|
||||
tempoServiceGraph: boolean;
|
||||
tempoSearch: boolean;
|
||||
prometheusMonaco: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -65,6 +65,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
trimDefaults: false,
|
||||
tempoServiceGraph: false,
|
||||
tempoSearch: false,
|
||||
prometheusMonaco: false,
|
||||
};
|
||||
licenseInfo: LicenseInfo = {} as LicenseInfo;
|
||||
rendererAvailable = false;
|
||||
|
@ -88,12 +88,14 @@ class UnthemedCodeEditor extends React.PureComponent<Props> {
|
||||
|
||||
handleBeforeMount = (monaco: Monaco) => {
|
||||
this.monaco = monaco;
|
||||
const { language, theme, getSuggestions } = this.props;
|
||||
const { language, theme, getSuggestions, onBeforeEditorMount } = this.props;
|
||||
defineThemes(monaco, theme);
|
||||
|
||||
if (getSuggestions) {
|
||||
this.completionCancel = registerSuggestions(monaco, language, getSuggestions);
|
||||
}
|
||||
|
||||
onBeforeEditorMount?.(monaco);
|
||||
};
|
||||
|
||||
handleOnMount = (editor: MonacoEditorType, monaco: Monaco) => {
|
||||
|
@ -4,6 +4,7 @@ import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
export type CodeEditorChangeHandler = (value: string) => void;
|
||||
export type CodeEditorSuggestionProvider = () => CodeEditorSuggestionItem[];
|
||||
|
||||
export type { monacoType as monacoTypes };
|
||||
export type Monaco = typeof monacoType;
|
||||
export type MonacoEditor = monacoType.editor.IStandaloneCodeEditor;
|
||||
export type MonacoOptions = monacoType.editor.IStandaloneEditorConstructionOptions;
|
||||
@ -19,6 +20,11 @@ export interface CodeEditorProps {
|
||||
showLineNumbers?: boolean;
|
||||
monacoOptions?: MonacoOptions;
|
||||
|
||||
/**
|
||||
* Callback before the editor has mounted that gives you raw access to monaco
|
||||
*/
|
||||
onBeforeEditorMount?: (monaco: Monaco) => void;
|
||||
|
||||
/**
|
||||
* Callback after the editor has mounted that gives you raw access to monaco
|
||||
*/
|
||||
|
@ -42,7 +42,14 @@ export { QueryField } from './QueryField/QueryField';
|
||||
|
||||
// Code editor
|
||||
export { CodeEditor } from './Monaco/CodeEditorLazy';
|
||||
export { Monaco, MonacoEditor, CodeEditorSuggestionItem, CodeEditorSuggestionItemKind } from './Monaco/types';
|
||||
export {
|
||||
Monaco,
|
||||
monacoTypes,
|
||||
MonacoEditor,
|
||||
MonacoOptions as CodeEditorMonacoOptions,
|
||||
CodeEditorSuggestionItem,
|
||||
CodeEditorSuggestionItemKind,
|
||||
} from './Monaco/types';
|
||||
export { variableSuggestionToCodeEditorSuggestion } from './Monaco/utils';
|
||||
|
||||
// TODO: namespace
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Plugin } from 'slate';
|
||||
import {
|
||||
SlatePrism,
|
||||
@ -28,6 +29,7 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { PrometheusDatasource } from '../datasource';
|
||||
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
|
||||
import { MonacoQueryFieldLazy } from './monaco-query-field/MonacoQueryFieldLazy';
|
||||
|
||||
export const RECORDING_RULES_GROUP = '__recording_rules__';
|
||||
|
||||
@ -279,6 +281,8 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
|
||||
const buttonDisabled = !(syntaxLoaded && hasMetrics);
|
||||
|
||||
const isMonacoEditorEnabled = config.featureToggles.prometheusMonaco;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -295,19 +299,29 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
||||
</button>
|
||||
|
||||
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15">
|
||||
<QueryField
|
||||
additionalPlugins={this.plugins}
|
||||
cleanText={cleanText}
|
||||
query={query.expr}
|
||||
onTypeahead={this.onTypeahead}
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onBlur={this.props.onBlur}
|
||||
onChange={this.onChangeQuery}
|
||||
onRunQuery={this.props.onRunQuery}
|
||||
placeholder={placeholder}
|
||||
portalOrigin="prometheus"
|
||||
syntaxLoaded={syntaxLoaded}
|
||||
/>
|
||||
{isMonacoEditorEnabled ? (
|
||||
<MonacoQueryFieldLazy
|
||||
languageProvider={languageProvider}
|
||||
history={this.props.history}
|
||||
onChange={this.onChangeQuery}
|
||||
onRunQuery={this.props.onRunQuery}
|
||||
initialValue={query.expr ?? ''}
|
||||
/>
|
||||
) : (
|
||||
<QueryField
|
||||
additionalPlugins={this.plugins}
|
||||
cleanText={cleanText}
|
||||
query={query.expr}
|
||||
onTypeahead={this.onTypeahead}
|
||||
onWillApplySuggestion={willApplySuggestion}
|
||||
onBlur={this.props.onBlur}
|
||||
onChange={this.onChangeQuery}
|
||||
onRunQuery={this.props.onRunQuery}
|
||||
placeholder={placeholder}
|
||||
portalOrigin="prometheus"
|
||||
syntaxLoaded={syntaxLoaded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{labelBrowserVisible && (
|
||||
|
@ -0,0 +1,110 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { CodeEditor, CodeEditorMonacoOptions } from '@grafana/ui';
|
||||
import { useLatest } from 'react-use';
|
||||
import { promLanguageDefinition } from 'monaco-promql';
|
||||
import { getCompletionProvider } from './monaco-completion-provider';
|
||||
import { Props } from './MonacoQueryFieldProps';
|
||||
|
||||
const options: CodeEditorMonacoOptions = {
|
||||
lineNumbers: 'off',
|
||||
minimap: { enabled: false },
|
||||
lineDecorationsWidth: 0,
|
||||
wordWrap: 'off',
|
||||
overviewRulerLanes: 0,
|
||||
overviewRulerBorder: false,
|
||||
folding: false,
|
||||
scrollBeyondLastLine: false,
|
||||
renderLineHighlight: 'none',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const MonacoQueryField = (props: Props) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { languageProvider, history, onChange, initialValue } = props;
|
||||
|
||||
const lpRef = useLatest(languageProvider);
|
||||
const historyRef = useLatest(history);
|
||||
|
||||
return (
|
||||
<div
|
||||
// NOTE: we will be setting inline-style-width/height on this element
|
||||
ref={containerRef}
|
||||
style={{
|
||||
// FIXME:
|
||||
// this is how the non-monaco query-editor is styled,
|
||||
// through the "gf-form" class
|
||||
// so to have the same effect, we do the same.
|
||||
// this should be applied somehow differently probably,
|
||||
// like a min-height on the whole row.
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<CodeEditor
|
||||
onBlur={onChange}
|
||||
monacoOptions={options}
|
||||
language="promql"
|
||||
value={initialValue}
|
||||
onBeforeEditorMount={(monaco) => {
|
||||
// we construct a DataProvider object
|
||||
const getSeries = (selector: string) => lpRef.current.getSeries(selector);
|
||||
|
||||
const getHistory = () =>
|
||||
Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined));
|
||||
|
||||
const getAllMetricNames = () => {
|
||||
const { metricsMetadata } = lpRef.current;
|
||||
const result = metricsMetadata == null ? [] : Object.keys(metricsMetadata);
|
||||
return Promise.resolve(result);
|
||||
};
|
||||
|
||||
const dataProvider = { getSeries, getHistory, getAllMetricNames };
|
||||
|
||||
const langId = promLanguageDefinition.id;
|
||||
monaco.languages.register(promLanguageDefinition);
|
||||
promLanguageDefinition.loader().then((mod) => {
|
||||
monaco.languages.setMonarchTokensProvider(langId, mod.language);
|
||||
monaco.languages.setLanguageConfiguration(langId, mod.languageConfiguration);
|
||||
const completionProvider = getCompletionProvider(monaco, dataProvider);
|
||||
monaco.languages.registerCompletionItemProvider(langId, completionProvider);
|
||||
});
|
||||
|
||||
// FIXME: should we unregister this at end end?
|
||||
}}
|
||||
onEditorDidMount={(editor, monaco) => {
|
||||
// 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:
|
||||
// <CodeEditor resizingMode="single-line"/>
|
||||
const updateElementHeight = () => {
|
||||
const containerDiv = containerRef.current;
|
||||
if (containerDiv !== null) {
|
||||
const pixelHeight = editor.getContentHeight();
|
||||
containerDiv.style.height = `${pixelHeight}px`;
|
||||
containerDiv.style.width = '100%';
|
||||
const pixelWidth = containerDiv.clientWidth;
|
||||
editor.layout({ width: pixelWidth, height: pixelHeight });
|
||||
}
|
||||
};
|
||||
|
||||
editor.onDidContentSizeChange(updateElementHeight);
|
||||
updateElementHeight();
|
||||
|
||||
// handle: shift + enter
|
||||
// FIXME: maybe move this functionality into CodeEditor?
|
||||
editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => {
|
||||
const text = editor.getValue();
|
||||
props.onChange(text);
|
||||
props.onRunQuery();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// we will lazy-load this module using React.lazy,
|
||||
// and that only supports default-exports,
|
||||
// so we have to default-export this, even if
|
||||
// it is agains the style-guidelines.
|
||||
|
||||
export default MonacoQueryField;
|
@ -0,0 +1,12 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Props } from './MonacoQueryFieldProps';
|
||||
|
||||
const Field = React.lazy(() => import(/* webpackChunkName: "prom-query-field" */ './MonacoQueryField'));
|
||||
|
||||
export const MonacoQueryFieldLazy = (props: Props) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Field {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
@ -0,0 +1,15 @@
|
||||
import { HistoryItem } from '@grafana/data';
|
||||
import { PromQuery } from '../../types';
|
||||
import type PromQlLanguageProvider from '../../language_provider';
|
||||
|
||||
// we need to store this in a separate file,
|
||||
// because we have an async-wrapper around,
|
||||
// the react-component, and it needs the same
|
||||
// props as the sync-component.
|
||||
export type Props = {
|
||||
initialValue: string;
|
||||
languageProvider: PromQlLanguageProvider;
|
||||
history: Array<HistoryItem<PromQuery>>;
|
||||
onChange: (query: string) => void;
|
||||
onRunQuery: () => void;
|
||||
};
|
@ -0,0 +1,140 @@
|
||||
import type { Intent, Label } from './intent';
|
||||
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
|
||||
import { FUNCTIONS } from '../../../promql';
|
||||
|
||||
type Completion = {
|
||||
label: string;
|
||||
insertText: string;
|
||||
triggerOnInsert?: boolean;
|
||||
};
|
||||
|
||||
export type DataProvider = {
|
||||
getHistory: () => Promise<string[]>;
|
||||
getAllMetricNames: () => Promise<string[]>;
|
||||
getSeries: (selector: string) => Promise<Record<string, string[]>>;
|
||||
};
|
||||
|
||||
// we order items like: history, functions, metrics
|
||||
|
||||
async function getAllMetricNamesCompletions(dataProvider: DataProvider): Promise<Completion[]> {
|
||||
const names = await dataProvider.getAllMetricNames();
|
||||
return names.map((text) => ({
|
||||
label: text,
|
||||
insertText: text,
|
||||
}));
|
||||
}
|
||||
|
||||
function getAllFunctionsCompletions(): Completion[] {
|
||||
return FUNCTIONS.map((f) => ({
|
||||
label: f.label,
|
||||
insertText: f.insertText ?? '', // i don't know what to do when this is nullish. it should not be.
|
||||
}));
|
||||
}
|
||||
|
||||
function getAllDurationsCompletions(): Completion[] {
|
||||
// FIXME: get a better list
|
||||
return ['5m', '1m', '30s', '15s'].map((text) => ({
|
||||
label: text,
|
||||
insertText: text,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getAllHistoryCompletions(dataProvider: DataProvider): Promise<Completion[]> {
|
||||
// function getAllHistoryCompletions(queryHistory: PromHistoryItem[]): Completion[] {
|
||||
// NOTE: the typescript types are wrong. historyItem.query.expr can be undefined
|
||||
const allHistory = await dataProvider.getHistory();
|
||||
// FIXME: find a better history-limit
|
||||
return allHistory.slice(0, 10).map((expr) => ({
|
||||
label: expr,
|
||||
insertText: expr,
|
||||
}));
|
||||
}
|
||||
|
||||
function makeSelector(metricName: string | undefined, labels: Label[]): string {
|
||||
const allLabels = [...labels];
|
||||
|
||||
// we transform the metricName to a label, if it exists
|
||||
if (metricName !== undefined) {
|
||||
allLabels.push({ name: '__name__', value: metricName });
|
||||
}
|
||||
|
||||
const allLabelTexts = allLabels.map((label) => `${label.name}="${label.value}"`);
|
||||
|
||||
return `{${allLabelTexts.join(',')}}`;
|
||||
}
|
||||
|
||||
async function getLabelNamesForCompletions(
|
||||
metric: string | undefined,
|
||||
suffix: string,
|
||||
triggerOnInsert: boolean,
|
||||
otherLabels: Label[],
|
||||
dataProvider: DataProvider
|
||||
): Promise<Completion[]> {
|
||||
const selector = makeSelector(metric, otherLabels);
|
||||
const data = await dataProvider.getSeries(selector);
|
||||
const possibleLabelNames = Object.keys(data); // all names from prometheus
|
||||
const usedLabelNames = new Set(otherLabels.map((l) => l.name)); // names used in the query
|
||||
const labelNames = possibleLabelNames.filter((l) => !usedLabelNames.has(l));
|
||||
return labelNames.map((text) => ({
|
||||
label: text,
|
||||
insertText: `${text}${suffix}`,
|
||||
triggerOnInsert,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getLabelNamesForSelectorCompletions(
|
||||
metric: string | undefined,
|
||||
otherLabels: Label[],
|
||||
dataProvider: DataProvider
|
||||
): Promise<Completion[]> {
|
||||
return getLabelNamesForCompletions(metric, '=', true, otherLabels, dataProvider);
|
||||
}
|
||||
async function getLabelNamesForByCompletions(
|
||||
metric: string | undefined,
|
||||
otherLabels: Label[],
|
||||
dataProvider: DataProvider
|
||||
): Promise<Completion[]> {
|
||||
return getLabelNamesForCompletions(metric, '', false, otherLabels, dataProvider);
|
||||
}
|
||||
|
||||
async function getLabelValuesForMetricCompletions(
|
||||
metric: string | undefined,
|
||||
labelName: string,
|
||||
otherLabels: Label[],
|
||||
dataProvider: DataProvider
|
||||
): Promise<Completion[]> {
|
||||
const selector = makeSelector(metric, otherLabels);
|
||||
const data = await dataProvider.getSeries(selector);
|
||||
const values = data[labelName] ?? [];
|
||||
return values.map((text) => ({
|
||||
label: text,
|
||||
insertText: `"${text}"`, // FIXME: escaping strange characters?
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getCompletions(intent: Intent, dataProvider: DataProvider): Promise<Completion[]> {
|
||||
switch (intent.type) {
|
||||
case 'ALL_DURATIONS':
|
||||
return getAllDurationsCompletions();
|
||||
case 'ALL_METRIC_NAMES':
|
||||
return getAllMetricNamesCompletions(dataProvider);
|
||||
case 'FUNCTIONS_AND_ALL_METRIC_NAMES': {
|
||||
const metricNames = await getAllMetricNamesCompletions(dataProvider);
|
||||
return [...getAllFunctionsCompletions(), ...metricNames];
|
||||
}
|
||||
case 'HISTORY_AND_FUNCTIONS_AND_ALL_METRIC_NAMES': {
|
||||
const metricNames = await getAllMetricNamesCompletions(dataProvider);
|
||||
const historyCompletions = await getAllHistoryCompletions(dataProvider);
|
||||
return [...historyCompletions, ...getAllFunctionsCompletions(), ...metricNames];
|
||||
}
|
||||
case 'LABEL_NAMES_FOR_SELECTOR':
|
||||
return getLabelNamesForSelectorCompletions(intent.metricName, intent.otherLabels, dataProvider);
|
||||
case 'LABEL_NAMES_FOR_BY':
|
||||
return getLabelNamesForByCompletions(intent.metricName, intent.otherLabels, dataProvider);
|
||||
case 'LABEL_VALUES':
|
||||
return getLabelValuesForMetricCompletions(intent.metricName, intent.labelName, intent.otherLabels, dataProvider);
|
||||
default:
|
||||
throw new NeverCaseError(intent);
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
import type { Monaco, monacoTypes } from '@grafana/ui';
|
||||
|
||||
import { getIntent } from './intent';
|
||||
import { getCompletions, DataProvider } from './completions';
|
||||
|
||||
export function getCompletionProvider(
|
||||
monaco: Monaco,
|
||||
dataProvider: DataProvider
|
||||
): monacoTypes.languages.CompletionItemProvider {
|
||||
const provideCompletionItems = (
|
||||
model: monacoTypes.editor.ITextModel,
|
||||
position: monacoTypes.Position
|
||||
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> => {
|
||||
const word = model.getWordAtPosition(position);
|
||||
const range =
|
||||
word != null
|
||||
? monaco.Range.lift({
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
})
|
||||
: monaco.Range.fromPositions(position);
|
||||
// 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,
|
||||
};
|
||||
const offset = model.getOffsetAt(positionClone);
|
||||
const intent = getIntent(model.getValue(), offset);
|
||||
const completionsPromise = intent != null ? getCompletions(intent, dataProvider) : Promise.resolve([]);
|
||||
return completionsPromise.then((items) => {
|
||||
// monaco by-default alphabetically orders the items.
|
||||
// to stop it, we use a number-as-string sortkey,
|
||||
// so that monaco keeps the order we use
|
||||
const maxIndexDigits = items.length.toString().length;
|
||||
const suggestions = items.map((item, index) => ({
|
||||
kind: monaco.languages.CompletionItemKind.Text,
|
||||
label: item.label,
|
||||
insertText: item.insertText,
|
||||
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
|
||||
range,
|
||||
command: item.triggerOnInsert
|
||||
? {
|
||||
id: 'editor.action.triggerSuggest',
|
||||
title: '',
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
return { suggestions };
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
triggerCharacters: ['{', ',', '[', '(', '='],
|
||||
provideCompletionItems,
|
||||
};
|
||||
}
|
@ -0,0 +1,123 @@
|
||||
import { getIntent, Intent } from './intent';
|
||||
|
||||
// we use the `^` character as the cursor-marker in the string.
|
||||
function assertIntent(situation: string, expectedIntent: Intent | null) {
|
||||
// first we find the cursor-position
|
||||
const pos = situation.indexOf('^');
|
||||
if (pos === -1) {
|
||||
throw new Error('cursor missing');
|
||||
}
|
||||
|
||||
// we remove the cursor-marker from the string
|
||||
const text = situation.replace('^', '');
|
||||
|
||||
// sanity check, make sure no more cursor-markers remain
|
||||
if (text.indexOf('^') !== -1) {
|
||||
throw new Error('multiple cursors');
|
||||
}
|
||||
|
||||
const result = getIntent(text, pos);
|
||||
|
||||
if (expectedIntent === null) {
|
||||
expect(result).toStrictEqual(null);
|
||||
} else {
|
||||
expect(result).toMatchObject(expectedIntent);
|
||||
}
|
||||
}
|
||||
|
||||
describe('intent', () => {
|
||||
it('handles things', () => {
|
||||
assertIntent('^', {
|
||||
type: 'HISTORY_AND_FUNCTIONS_AND_ALL_METRIC_NAMES',
|
||||
});
|
||||
|
||||
assertIntent('sum(one) / ^', {
|
||||
type: 'FUNCTIONS_AND_ALL_METRIC_NAMES',
|
||||
});
|
||||
|
||||
assertIntent('sum(^)', {
|
||||
type: 'ALL_METRIC_NAMES',
|
||||
});
|
||||
|
||||
assertIntent('sum(one) / sum(^)', {
|
||||
type: 'ALL_METRIC_NAMES',
|
||||
});
|
||||
|
||||
assertIntent('something{}[^]', {
|
||||
type: 'ALL_DURATIONS',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles label names', () => {
|
||||
assertIntent('something{^}', {
|
||||
type: 'LABEL_NAMES_FOR_SELECTOR',
|
||||
metricName: 'something',
|
||||
otherLabels: [],
|
||||
});
|
||||
|
||||
assertIntent('sum(something) by (^)', {
|
||||
type: 'LABEL_NAMES_FOR_BY',
|
||||
metricName: 'something',
|
||||
otherLabels: [],
|
||||
});
|
||||
|
||||
assertIntent('sum by (^) (something)', {
|
||||
type: 'LABEL_NAMES_FOR_BY',
|
||||
metricName: 'something',
|
||||
otherLabels: [],
|
||||
});
|
||||
|
||||
assertIntent('something{one="val1",two="val2",^}', {
|
||||
type: 'LABEL_NAMES_FOR_SELECTOR',
|
||||
metricName: 'something',
|
||||
otherLabels: [
|
||||
{ name: 'one', value: 'val1' },
|
||||
{ name: 'two', value: 'val2' },
|
||||
],
|
||||
});
|
||||
|
||||
assertIntent('{^}', {
|
||||
type: 'LABEL_NAMES_FOR_SELECTOR',
|
||||
otherLabels: [],
|
||||
});
|
||||
|
||||
assertIntent('{one="val1",^}', {
|
||||
type: 'LABEL_NAMES_FOR_SELECTOR',
|
||||
otherLabels: [{ name: 'one', value: 'val1' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('handles label values', () => {
|
||||
assertIntent('something{job=^}', {
|
||||
type: 'LABEL_VALUES',
|
||||
metricName: 'something',
|
||||
labelName: 'job',
|
||||
otherLabels: [],
|
||||
});
|
||||
|
||||
assertIntent('something{job=^,host="h1"}', {
|
||||
type: 'LABEL_VALUES',
|
||||
metricName: 'something',
|
||||
labelName: 'job',
|
||||
otherLabels: [{ name: 'host', value: 'h1' }],
|
||||
});
|
||||
|
||||
assertIntent('{job=^,host="h1"}', {
|
||||
type: 'LABEL_VALUES',
|
||||
labelName: 'job',
|
||||
otherLabels: [{ name: 'host', value: 'h1' }],
|
||||
});
|
||||
|
||||
assertIntent('something{one="val1",two="val2",three=^,four="val4",five="val5"}', {
|
||||
type: 'LABEL_VALUES',
|
||||
metricName: 'something',
|
||||
labelName: 'three',
|
||||
otherLabels: [
|
||||
{ name: 'one', value: 'val1' },
|
||||
{ name: 'two', value: 'val2' },
|
||||
{ name: 'four', value: 'val4' },
|
||||
{ name: 'five', value: 'val5' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,447 @@
|
||||
import { parser } from 'lezer-promql';
|
||||
import type { Tree, SyntaxNode } from 'lezer-tree';
|
||||
import { NeverCaseError } from './util';
|
||||
|
||||
type Direction = 'parent' | 'firstChild' | 'lastChild';
|
||||
type NodeTypeName =
|
||||
| '⚠' // this is used as error-name
|
||||
| 'AggregateExpr'
|
||||
| 'AggregateModifier'
|
||||
| 'FunctionCallBody'
|
||||
| 'GroupingLabels'
|
||||
| 'Identifier'
|
||||
| 'LabelMatcher'
|
||||
| 'LabelMatchers'
|
||||
| 'LabelMatchList'
|
||||
| 'LabelName'
|
||||
| 'MetricIdentifier'
|
||||
| 'PromQL'
|
||||
| 'StringLiteral'
|
||||
| 'VectorSelector'
|
||||
| 'MatrixSelector';
|
||||
|
||||
type Path = Array<[Direction, NodeTypeName]>;
|
||||
|
||||
function move(node: SyntaxNode, direction: Direction): SyntaxNode | null {
|
||||
switch (direction) {
|
||||
case 'parent':
|
||||
return node.parent;
|
||||
case 'firstChild':
|
||||
return node.firstChild;
|
||||
case 'lastChild':
|
||||
return node.lastChild;
|
||||
default:
|
||||
throw new NeverCaseError(direction);
|
||||
}
|
||||
}
|
||||
|
||||
function walk(node: SyntaxNode, path: Path): SyntaxNode | null {
|
||||
let current: SyntaxNode | null = node;
|
||||
for (const [direction, expectedType] of path) {
|
||||
current = move(current, direction);
|
||||
if (current === null) {
|
||||
// we could not move in the direction, we stop
|
||||
return null;
|
||||
}
|
||||
if (current.type.name !== expectedType) {
|
||||
// the reached node has wrong type, we stop
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function getNodeText(node: SyntaxNode, text: string): string {
|
||||
return text.slice(node.from, node.to);
|
||||
}
|
||||
|
||||
function parsePromQLStringLiteral(text: string): string {
|
||||
// FIXME: support https://prometheus.io/docs/prometheus/latest/querying/basics/#string-literals
|
||||
// FIXME: maybe check other promql code, if all is supported or not
|
||||
// we start with double-quotes
|
||||
if (text.startsWith('"') && text.endsWith('"')) {
|
||||
if (text.indexOf('\\') !== -1) {
|
||||
throw new Error('FIXME: escape-sequences not supported in label-values');
|
||||
}
|
||||
return text.slice(1, text.length - 1);
|
||||
} else {
|
||||
throw new Error('FIXME: invalid string literal');
|
||||
}
|
||||
}
|
||||
|
||||
export type Label = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Intent =
|
||||
| {
|
||||
type: 'ALL_METRIC_NAMES';
|
||||
}
|
||||
| {
|
||||
type: 'FUNCTIONS_AND_ALL_METRIC_NAMES';
|
||||
}
|
||||
| {
|
||||
type: 'HISTORY_AND_FUNCTIONS_AND_ALL_METRIC_NAMES';
|
||||
}
|
||||
| {
|
||||
type: 'ALL_DURATIONS';
|
||||
}
|
||||
| {
|
||||
type: 'LABEL_NAMES_FOR_SELECTOR';
|
||||
metricName?: string;
|
||||
otherLabels: Label[];
|
||||
}
|
||||
| {
|
||||
type: 'LABEL_NAMES_FOR_BY';
|
||||
metricName: string;
|
||||
otherLabels: Label[];
|
||||
}
|
||||
| {
|
||||
type: 'LABEL_VALUES';
|
||||
metricName?: string;
|
||||
labelName: string;
|
||||
otherLabels: Label[];
|
||||
};
|
||||
|
||||
type Resolver = {
|
||||
path: NodeTypeName[];
|
||||
fun: (node: SyntaxNode, text: string, pos: number) => Intent | null;
|
||||
};
|
||||
|
||||
function isPathMatch(resolverPath: string[], cursorPath: string[]): boolean {
|
||||
return resolverPath.every((item, index) => item === cursorPath[index]);
|
||||
}
|
||||
|
||||
const ERROR_NODE_NAME: NodeTypeName = '⚠'; // this is used as error-name
|
||||
|
||||
const RESOLVERS: Resolver[] = [
|
||||
{
|
||||
path: ['LabelMatchers', 'VectorSelector'],
|
||||
fun: resolveLabelKeysWithEquals,
|
||||
},
|
||||
{
|
||||
path: ['PromQL'],
|
||||
fun: resolveTopLevel,
|
||||
},
|
||||
{
|
||||
path: ['FunctionCallBody'],
|
||||
fun: resolveInFunction,
|
||||
},
|
||||
{
|
||||
path: [ERROR_NODE_NAME, 'LabelMatcher'],
|
||||
fun: resolveLabelMatcherError,
|
||||
},
|
||||
{
|
||||
path: [ERROR_NODE_NAME, 'MatrixSelector'],
|
||||
fun: resolveDurations,
|
||||
},
|
||||
{
|
||||
path: ['GroupingLabels'],
|
||||
fun: resolveLabelsForGrouping,
|
||||
},
|
||||
];
|
||||
|
||||
function getLabel(labelMatcherNode: SyntaxNode, text: string): Label | null {
|
||||
if (labelMatcherNode.type.name !== 'LabelMatcher') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameNode = walk(labelMatcherNode, [['firstChild', 'LabelName']]);
|
||||
|
||||
if (nameNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valueNode = walk(labelMatcherNode, [['lastChild', 'StringLiteral']]);
|
||||
|
||||
if (valueNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = getNodeText(nameNode, text);
|
||||
const value = parsePromQLStringLiteral(getNodeText(valueNode, text));
|
||||
|
||||
return { name, value };
|
||||
}
|
||||
function getLabels(labelMatchersNode: SyntaxNode, text: string): Label[] {
|
||||
if (labelMatchersNode.type.name !== 'LabelMatchers') {
|
||||
return [];
|
||||
}
|
||||
|
||||
let listNode: SyntaxNode | null = walk(labelMatchersNode, [['firstChild', 'LabelMatchList']]);
|
||||
|
||||
const labels: Label[] = [];
|
||||
|
||||
while (listNode !== null) {
|
||||
const matcherNode = walk(listNode, [['lastChild', 'LabelMatcher']]);
|
||||
if (matcherNode === null) {
|
||||
// unexpected, we stop
|
||||
return [];
|
||||
}
|
||||
|
||||
const label = getLabel(matcherNode, text);
|
||||
if (label !== null) {
|
||||
labels.push(label);
|
||||
}
|
||||
|
||||
// there might be more labels
|
||||
listNode = walk(listNode, [['firstChild', 'LabelMatchList']]);
|
||||
}
|
||||
|
||||
// our labels-list is last-first, so we reverse it
|
||||
labels.reverse();
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
function getNodeChildren(node: SyntaxNode): SyntaxNode[] {
|
||||
let child: SyntaxNode | null = node.firstChild;
|
||||
const children: SyntaxNode[] = [];
|
||||
while (child !== null) {
|
||||
children.push(child);
|
||||
child = child.nextSibling;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function getNodeInSubtree(node: SyntaxNode, typeName: NodeTypeName): SyntaxNode | null {
|
||||
// first we try the current node
|
||||
if (node.type.name === typeName) {
|
||||
return node;
|
||||
}
|
||||
|
||||
// then we try the children
|
||||
const children = getNodeChildren(node);
|
||||
for (const child of children) {
|
||||
const n = getNodeInSubtree(child, typeName);
|
||||
if (n !== null) {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLabelsForGrouping(node: SyntaxNode, text: string, pos: number): Intent | null {
|
||||
const aggrExpNode = walk(node, [
|
||||
['parent', 'AggregateModifier'],
|
||||
['parent', 'AggregateExpr'],
|
||||
]);
|
||||
if (aggrExpNode === null) {
|
||||
return null;
|
||||
}
|
||||
const bodyNode = aggrExpNode.getChild('FunctionCallBody');
|
||||
if (bodyNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metricIdNode = getNodeInSubtree(bodyNode, 'MetricIdentifier');
|
||||
if (metricIdNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const idNode = walk(metricIdNode, [['firstChild', 'Identifier']]);
|
||||
if (idNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metricName = getNodeText(idNode, text);
|
||||
return {
|
||||
type: 'LABEL_NAMES_FOR_BY',
|
||||
metricName,
|
||||
otherLabels: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLabelMatcherError(node: SyntaxNode, text: string, pos: number): Intent | null {
|
||||
// we are probably in the scenario where the user is before entering the
|
||||
// label-value, like `{job=^}` (^ marks the cursor)
|
||||
const parent = walk(node, [['parent', 'LabelMatcher']]);
|
||||
if (parent === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelNameNode = walk(parent, [['firstChild', 'LabelName']]);
|
||||
if (labelNameNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const labelName = getNodeText(labelNameNode, text);
|
||||
|
||||
// now we need to go up, to the parent of LabelMatcher,
|
||||
// there can be one or many `LabelMatchList` parents, we have
|
||||
// to go through all of them
|
||||
|
||||
const firstListNode = walk(parent, [['parent', 'LabelMatchList']]);
|
||||
if (firstListNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let listNode = firstListNode;
|
||||
|
||||
// we keep going through the parent-nodes
|
||||
// as long as they are LabelMatchList.
|
||||
// as soon as we reawch LabelMatchers, we stop
|
||||
let labelMatchersNode: SyntaxNode | null = null;
|
||||
while (labelMatchersNode === null) {
|
||||
const p = listNode.parent;
|
||||
if (p === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { name } = p.type;
|
||||
|
||||
switch (name) {
|
||||
case 'LabelMatchList':
|
||||
//we keep looping
|
||||
listNode = p;
|
||||
continue;
|
||||
case 'LabelMatchers':
|
||||
// we reached the end, we can stop the loop
|
||||
labelMatchersNode = p;
|
||||
continue;
|
||||
default:
|
||||
// we reached some other node, we stop
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// now we need to find the other names
|
||||
const otherLabels = getLabels(labelMatchersNode, text);
|
||||
|
||||
const metricNameNode = walk(labelMatchersNode, [
|
||||
['parent', 'VectorSelector'],
|
||||
['firstChild', 'MetricIdentifier'],
|
||||
['firstChild', 'Identifier'],
|
||||
]);
|
||||
|
||||
if (metricNameNode === null) {
|
||||
// we are probably in a situation without a metric name
|
||||
return {
|
||||
type: 'LABEL_VALUES',
|
||||
labelName,
|
||||
otherLabels,
|
||||
};
|
||||
}
|
||||
|
||||
const metricName = getNodeText(metricNameNode, text);
|
||||
|
||||
return {
|
||||
type: 'LABEL_VALUES',
|
||||
metricName,
|
||||
labelName,
|
||||
otherLabels,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTopLevel(node: SyntaxNode, text: string, pos: number): Intent {
|
||||
return {
|
||||
type: 'FUNCTIONS_AND_ALL_METRIC_NAMES',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInFunction(node: SyntaxNode, text: string, pos: number): Intent {
|
||||
return {
|
||||
type: 'ALL_METRIC_NAMES',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveDurations(node: SyntaxNode, text: string, pos: number): Intent {
|
||||
return {
|
||||
type: 'ALL_DURATIONS',
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLabelKeysWithEquals(node: SyntaxNode, text: string, pos: number): Intent | null {
|
||||
const metricNameNode = walk(node, [
|
||||
['parent', 'VectorSelector'],
|
||||
['firstChild', 'MetricIdentifier'],
|
||||
['firstChild', 'Identifier'],
|
||||
]);
|
||||
|
||||
const otherLabels = getLabels(node, text);
|
||||
|
||||
if (metricNameNode === null) {
|
||||
// we are probably in a situation without a metric name.
|
||||
return {
|
||||
type: 'LABEL_NAMES_FOR_SELECTOR',
|
||||
otherLabels,
|
||||
};
|
||||
}
|
||||
|
||||
const metricName = getNodeText(metricNameNode, text);
|
||||
|
||||
return {
|
||||
type: 'LABEL_NAMES_FOR_SELECTOR',
|
||||
metricName,
|
||||
otherLabels,
|
||||
};
|
||||
}
|
||||
|
||||
// we find the first error-node in the tree that is at the cursor-position.
|
||||
// NOTE: this might be too slow, might need to optimize it
|
||||
// (ideas: we do not need to go into every subtree, based on from/to)
|
||||
// also, only go to places that are in the sub-tree of the node found
|
||||
// by default by lezer. problem is, `next()` will go upward too,
|
||||
// and we do not want to go higher than our node
|
||||
function getErrorNode(tree: Tree, pos: number): SyntaxNode | null {
|
||||
const cur = tree.cursor(pos);
|
||||
while (true) {
|
||||
if (cur.from === pos && cur.to === pos) {
|
||||
const { node } = cur;
|
||||
if (node.type.isError) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
if (!cur.next()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getIntent(text: string, pos: number): Intent | null {
|
||||
// there is a special-case when we are at the start of writing text,
|
||||
// so we handle that case first
|
||||
|
||||
if (text === '') {
|
||||
return {
|
||||
type: 'HISTORY_AND_FUNCTIONS_AND_ALL_METRIC_NAMES',
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
PromQL
|
||||
Expr
|
||||
VectorSelector
|
||||
LabelMatchers
|
||||
*/
|
||||
const tree = parser.parse(text);
|
||||
|
||||
// if the tree contains error, it is very probable that
|
||||
// our node is one of those error-nodes.
|
||||
// also, if there are errors, the node lezer finds us,
|
||||
// might not be the best node.
|
||||
// so first we check if there is an error-node at the cursor-position
|
||||
const maybeErrorNode = getErrorNode(tree, pos);
|
||||
|
||||
const cur = maybeErrorNode != null ? maybeErrorNode.cursor : tree.cursor(pos);
|
||||
const currentNode = cur.node;
|
||||
|
||||
const names = [cur.name];
|
||||
while (cur.parent()) {
|
||||
names.push(cur.name);
|
||||
}
|
||||
|
||||
for (let resolver of RESOLVERS) {
|
||||
// i do not use a foreach because i want to stop as soon
|
||||
// as i find something
|
||||
if (isPathMatch(resolver.path, names)) {
|
||||
return resolver.fun(currentNode, text, pos);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
// this helper class is used to make typescript warn you when you forget
|
||||
// a case-block in a switch statement.
|
||||
// example code that triggers the typescript-error:
|
||||
//
|
||||
// const x:'A'|'B'|'C' = 'A';
|
||||
//
|
||||
// switch(x) {
|
||||
// case 'A':
|
||||
// // something
|
||||
// case 'B':
|
||||
// // something
|
||||
// default:
|
||||
// throw new NeverCaseError(x);
|
||||
// }
|
||||
//
|
||||
//
|
||||
// typescript will show an error in this case,
|
||||
// when you add the missing `case 'C'` code,
|
||||
// the problem will be fixed.
|
||||
|
||||
export class NeverCaseError extends Error {
|
||||
constructor(value: never) {
|
||||
super('should never happen');
|
||||
}
|
||||
}
|
22
yarn.lock
22
yarn.lock
@ -15383,6 +15383,23 @@ levn@^0.4.1:
|
||||
prelude-ls "^1.2.1"
|
||||
type-check "~0.4.0"
|
||||
|
||||
lezer-promql@0.20.0:
|
||||
version "0.20.0"
|
||||
resolved "https://registry.yarnpkg.com/lezer-promql/-/lezer-promql-0.20.0.tgz#d5d233fa5dfc5fb7fcd3efe0022fd31dd2f2d539"
|
||||
integrity sha512-1CHG77fFghl032FfHT33buGyAHiTaMy2fqicEhcp2wWnbxZxS+Jt6gMzEUaf/TmRTIUJofj9uLar7iL22Jazug==
|
||||
|
||||
lezer-tree@0.13.2, lezer-tree@^0.13.2:
|
||||
version "0.13.2"
|
||||
resolved "https://registry.yarnpkg.com/lezer-tree/-/lezer-tree-0.13.2.tgz#00f4671309b15c27b131f637e430ce2d4d5f7065"
|
||||
integrity sha512-15ZxW8TxVNAOkHIo43Iouv4zbSkQQ5chQHBpwXcD2bBFz46RB4jYLEEww5l1V0xyIx9U2clSyyrLes+hAUFrGQ==
|
||||
|
||||
lezer@0.13.5:
|
||||
version "0.13.5"
|
||||
resolved "https://registry.yarnpkg.com/lezer/-/lezer-0.13.5.tgz#6000536bca7e24a5bd62e8cb4feff28b37e7dd8f"
|
||||
integrity sha512-cAiMQZGUo2BD8mpcz7Nv1TlKzWP7YIdIRrX41CiP5bk5t4GHxskOxWUx2iAOuHlz8dO+ivbuXr0J1bfHsWD+lQ==
|
||||
dependencies:
|
||||
lezer-tree "^0.13.2"
|
||||
|
||||
lilconfig@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.3.tgz#68f3005e921dafbd2a2afb48379986aa6d2579fd"
|
||||
@ -16527,6 +16544,11 @@ monaco-editor@0.21.2:
|
||||
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.21.2.tgz#37054e63e480d51a2dd17d609dcfb192304d5605"
|
||||
integrity sha512-jS51RLuzMaoJpYbu7F6TPuWpnWTLD4kjRW0+AZzcryvbxrTwhNy1KC9yboyKpgMTahpUbDUsuQULoo0GV1EPqg==
|
||||
|
||||
monaco-promql@^1.7.2:
|
||||
version "1.7.2"
|
||||
resolved "https://registry.yarnpkg.com/monaco-promql/-/monaco-promql-1.7.2.tgz#e17cacb6abd0adece90a72a0f67221293dc64664"
|
||||
integrity sha512-T8k6tKalUbhgD+anB5Nw9K9Cb/yWhGq5YU9PDhALyS+hkFsrk0SwAfRBL1uqGNu+XGJ4xCGOwzhDFLiemRf+bw==
|
||||
|
||||
moo-color@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.2.tgz#837c40758d2d58763825d1359a84e330531eca64"
|
||||
|
Loading…
Reference in New Issue
Block a user