mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -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",
|
"jquery": "3.5.1",
|
||||||
"json-source-map": "0.6.1",
|
"json-source-map": "0.6.1",
|
||||||
"jsurl": "^0.1.5",
|
"jsurl": "^0.1.5",
|
||||||
|
"lezer": "0.13.5",
|
||||||
|
"lezer-promql": "0.20.0",
|
||||||
|
"lezer-tree": "0.13.2",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"logfmt": "^1.3.2",
|
"logfmt": "^1.3.2",
|
||||||
"lru-cache": "^5.1.1",
|
"lru-cache": "^5.1.1",
|
||||||
"memoize-one": "5.1.1",
|
"memoize-one": "5.1.1",
|
||||||
"moment": "2.29.1",
|
"moment": "2.29.1",
|
||||||
"moment-timezone": "0.5.33",
|
"moment-timezone": "0.5.33",
|
||||||
|
"monaco-promql": "^1.7.2",
|
||||||
"mousetrap": "1.6.5",
|
"mousetrap": "1.6.5",
|
||||||
"mousetrap-global-bind": "1.1.0",
|
"mousetrap-global-bind": "1.1.0",
|
||||||
"ol": "^6.5.0",
|
"ol": "^6.5.0",
|
||||||
|
@ -50,6 +50,7 @@ export interface FeatureToggles {
|
|||||||
accesscontrol: boolean;
|
accesscontrol: boolean;
|
||||||
tempoServiceGraph: boolean;
|
tempoServiceGraph: boolean;
|
||||||
tempoSearch: boolean;
|
tempoSearch: boolean;
|
||||||
|
prometheusMonaco: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -65,6 +65,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
|||||||
trimDefaults: false,
|
trimDefaults: false,
|
||||||
tempoServiceGraph: false,
|
tempoServiceGraph: false,
|
||||||
tempoSearch: false,
|
tempoSearch: false,
|
||||||
|
prometheusMonaco: false,
|
||||||
};
|
};
|
||||||
licenseInfo: LicenseInfo = {} as LicenseInfo;
|
licenseInfo: LicenseInfo = {} as LicenseInfo;
|
||||||
rendererAvailable = false;
|
rendererAvailable = false;
|
||||||
|
@ -88,12 +88,14 @@ class UnthemedCodeEditor extends React.PureComponent<Props> {
|
|||||||
|
|
||||||
handleBeforeMount = (monaco: Monaco) => {
|
handleBeforeMount = (monaco: Monaco) => {
|
||||||
this.monaco = monaco;
|
this.monaco = monaco;
|
||||||
const { language, theme, getSuggestions } = this.props;
|
const { language, theme, getSuggestions, onBeforeEditorMount } = this.props;
|
||||||
defineThemes(monaco, theme);
|
defineThemes(monaco, theme);
|
||||||
|
|
||||||
if (getSuggestions) {
|
if (getSuggestions) {
|
||||||
this.completionCancel = registerSuggestions(monaco, language, getSuggestions);
|
this.completionCancel = registerSuggestions(monaco, language, getSuggestions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeEditorMount?.(monaco);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleOnMount = (editor: MonacoEditorType, monaco: 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 CodeEditorChangeHandler = (value: string) => void;
|
||||||
export type CodeEditorSuggestionProvider = () => CodeEditorSuggestionItem[];
|
export type CodeEditorSuggestionProvider = () => CodeEditorSuggestionItem[];
|
||||||
|
|
||||||
|
export type { monacoType as monacoTypes };
|
||||||
export type Monaco = typeof monacoType;
|
export type Monaco = typeof monacoType;
|
||||||
export type MonacoEditor = monacoType.editor.IStandaloneCodeEditor;
|
export type MonacoEditor = monacoType.editor.IStandaloneCodeEditor;
|
||||||
export type MonacoOptions = monacoType.editor.IStandaloneEditorConstructionOptions;
|
export type MonacoOptions = monacoType.editor.IStandaloneEditorConstructionOptions;
|
||||||
@ -19,6 +20,11 @@ export interface CodeEditorProps {
|
|||||||
showLineNumbers?: boolean;
|
showLineNumbers?: boolean;
|
||||||
monacoOptions?: MonacoOptions;
|
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
|
* Callback after the editor has mounted that gives you raw access to monaco
|
||||||
*/
|
*/
|
||||||
|
@ -42,7 +42,14 @@ export { QueryField } from './QueryField/QueryField';
|
|||||||
|
|
||||||
// Code editor
|
// Code editor
|
||||||
export { CodeEditor } from './Monaco/CodeEditorLazy';
|
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';
|
export { variableSuggestionToCodeEditorSuggestion } from './Monaco/utils';
|
||||||
|
|
||||||
// TODO: namespace
|
// TODO: namespace
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
import { Plugin } from 'slate';
|
import { Plugin } from 'slate';
|
||||||
import {
|
import {
|
||||||
SlatePrism,
|
SlatePrism,
|
||||||
@ -28,6 +29,7 @@ import {
|
|||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { PrometheusDatasource } from '../datasource';
|
import { PrometheusDatasource } from '../datasource';
|
||||||
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
|
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
|
||||||
|
import { MonacoQueryFieldLazy } from './monaco-query-field/MonacoQueryFieldLazy';
|
||||||
|
|
||||||
export const RECORDING_RULES_GROUP = '__recording_rules__';
|
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 chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
|
||||||
const buttonDisabled = !(syntaxLoaded && hasMetrics);
|
const buttonDisabled = !(syntaxLoaded && hasMetrics);
|
||||||
|
|
||||||
|
const isMonacoEditorEnabled = config.featureToggles.prometheusMonaco;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -295,6 +299,15 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15">
|
<div className="gf-form gf-form--grow flex-shrink-1 min-width-15">
|
||||||
|
{isMonacoEditorEnabled ? (
|
||||||
|
<MonacoQueryFieldLazy
|
||||||
|
languageProvider={languageProvider}
|
||||||
|
history={this.props.history}
|
||||||
|
onChange={this.onChangeQuery}
|
||||||
|
onRunQuery={this.props.onRunQuery}
|
||||||
|
initialValue={query.expr ?? ''}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<QueryField
|
<QueryField
|
||||||
additionalPlugins={this.plugins}
|
additionalPlugins={this.plugins}
|
||||||
cleanText={cleanText}
|
cleanText={cleanText}
|
||||||
@ -308,6 +321,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
|
|||||||
portalOrigin="prometheus"
|
portalOrigin="prometheus"
|
||||||
syntaxLoaded={syntaxLoaded}
|
syntaxLoaded={syntaxLoaded}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{labelBrowserVisible && (
|
{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"
|
prelude-ls "^1.2.1"
|
||||||
type-check "~0.4.0"
|
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:
|
lilconfig@^2.0.3:
|
||||||
version "2.0.3"
|
version "2.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.3.tgz#68f3005e921dafbd2a2afb48379986aa6d2579fd"
|
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"
|
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.21.2.tgz#37054e63e480d51a2dd17d609dcfb192304d5605"
|
||||||
integrity sha512-jS51RLuzMaoJpYbu7F6TPuWpnWTLD4kjRW0+AZzcryvbxrTwhNy1KC9yboyKpgMTahpUbDUsuQULoo0GV1EPqg==
|
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:
|
moo-color@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.2.tgz#837c40758d2d58763825d1359a84e330531eca64"
|
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.2.tgz#837c40758d2d58763825d1359a84e330531eca64"
|
||||||
|
Loading…
Reference in New Issue
Block a user