Skip to content

Commit 0e5a873

Browse files
author
Simon Bordeyne
committed
wip: add first draft of autocomplete engine for LogsQL
Based on the code for the official Loki datasource. LogsQL and LogQL are actually pretty similar, making it a good base to get started. Currently implemented: - stream autocomplete - syntax highlighting for LogQL
1 parent 76bda9d commit 0e5a873

File tree

14 files changed

+2089
-27
lines changed

14 files changed

+2089
-27
lines changed

src/components/QueryEditor/QueryField.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const QueryField: React.FC<QueryFieldProps> = (
1818
history,
1919
onRunQuery,
2020
onChange,
21+
datasource,
2122
'data-testid': dataTestId
2223
}) => {
2324

@@ -35,6 +36,7 @@ const QueryField: React.FC<QueryFieldProps> = (
3536
<MonacoQueryFieldWrapper
3637
runQueryOnBlur={false}
3738
history={history ?? []}
39+
datasource={datasource}
3840
onChange={onChangeQuery}
3941
onRunQuery={onRunQuery}
4042
initialValue={query.expr ?? ''}

src/components/monaco-query-field/MonacoQueryField.tsx

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { css } from '@emotion/css';
2-
import React, { useRef } from 'react';
2+
import React, { useEffect, useRef } from 'react';
33
import { useLatest } from 'react-use';
44

55
import { GrafanaTheme2 } from '@grafana/data';
66
import { selectors } from '@grafana/e2e-selectors';
7-
import { useTheme2, ReactMonacoEditor, monacoTypes } from '@grafana/ui';
7+
import { useTheme2, ReactMonacoEditor, monacoTypes, Monaco } from '@grafana/ui';
8+
9+
import { languageConfiguration, monarchlanguage } from '../../language';
810

911
import { Props } from './MonacoQueryFieldProps';
12+
import { CompletionDataProvider } from './completion/CompletionDataProvider';
13+
import { getCompletionProvider, getSuggestOptions } from './completion/completionUtils';
1014

1115
const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
1216
codeLens: false,
@@ -35,6 +39,7 @@ const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
3539
horizontalScrollbarSize: 0,
3640
},
3741
scrollBeyondLastLine: false,
42+
suggest: getSuggestOptions(),
3843
suggestFontSize: 12,
3944
wordWrap: 'on',
4045
};
@@ -48,6 +53,25 @@ const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
4853
// up & down. this we want to avoid)
4954
const EDITOR_HEIGHT_OFFSET = 2;
5055

56+
// we must only run the lang-setup code once
57+
let LANGUAGE_SETUP_STARTED = false;
58+
const LANG_ID = 'victorialogs-logsql';
59+
60+
function ensureVictoriaLogsLogsQL(monaco: Monaco) {
61+
if (LANGUAGE_SETUP_STARTED === false) {
62+
LANGUAGE_SETUP_STARTED = true;
63+
monaco.languages.register({ id: LANG_ID });
64+
65+
monaco.languages.setMonarchTokensProvider(LANG_ID, monarchlanguage);
66+
monaco.languages.setLanguageConfiguration(LANG_ID, {
67+
...languageConfiguration,
68+
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()+\[{\]}\\|;:',.<>\/?\s]+)/g,
69+
// Default: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g
70+
// Removed `"`, `=`, and `-`, from the exclusion list, so now the completion provider can decide to overwrite any matching words, or just insert text at the cursor
71+
});
72+
}
73+
}
74+
5175
const getStyles = (theme: GrafanaTheme2, placeholder: string) => {
5276
return {
5377
container: css`
@@ -67,33 +91,66 @@ const getStyles = (theme: GrafanaTheme2, placeholder: string) => {
6791
const MonacoQueryField = (props: Props) => {
6892
// we need only one instance of `overrideServices` during the lifetime of the react component
6993
const containerRef = useRef<HTMLDivElement>(null);
70-
const { onBlur, onRunQuery, initialValue, placeholder, readOnly } = props;
94+
const { onBlur, onRunQuery, initialValue, placeholder, readOnly, history, timeRange, datasource } = props;
7195

7296
const onRunQueryRef = useLatest(onRunQuery);
7397
const onBlurRef = useLatest(onBlur);
98+
const historyRef = useLatest(history);
99+
const langProviderRef = useLatest(datasource.languageProvider);
100+
const completionDataProviderRef = useRef<CompletionDataProvider | null>(null);
101+
const autocompleteCleanupCallback = useRef<(() => void) | null>(null);
74102

75103
const theme = useTheme2();
76104
const styles = getStyles(theme, placeholder);
77105

106+
useEffect(() => {
107+
// when we unmount, we unregister the autocomplete-function, if it was registered
108+
return () => {
109+
autocompleteCleanupCallback.current?.();
110+
};
111+
}, []);
112+
78113
return (
79-
<div
80-
aria-label={selectors.components.QueryField.container}
81-
className={styles.container}
82-
ref={containerRef}
83-
>
114+
<div aria-label={selectors.components.QueryField.container} className={styles.container} ref={containerRef}>
84115
<ReactMonacoEditor
85-
options={{
86-
...options,
87-
readOnly
88-
}}
89-
language="promql"
116+
options={{ ...options, readOnly }}
117+
language={LANG_ID}
90118
value={initialValue}
119+
beforeMount={(monaco) => {
120+
ensureVictoriaLogsLogsQL(monaco);
121+
}}
91122
onMount={(editor, monaco) => {
92123
// we setup on-blur
93124
editor.onDidBlurEditorWidget(() => {
94125
onBlurRef.current(editor.getValue());
95126
});
96127

128+
const dataProvider = new CompletionDataProvider(langProviderRef.current!, historyRef, timeRange);
129+
completionDataProviderRef.current = dataProvider;
130+
const completionProvider = getCompletionProvider(monaco, dataProvider);
131+
132+
// completion-providers in monaco are not registered directly to editor-instances,
133+
// they are registered to languages. this makes it hard for us to have
134+
// separate completion-providers for every query-field-instance
135+
// (but we need that, because they might connect to different datasources).
136+
// the trick we do is, we wrap the callback in a "proxy",
137+
// and in the proxy, the first thing is, we check if we are called from
138+
// "our editor instance", and if not, we just return nothing. if yes,
139+
// we call the completion-provider.
140+
const filteringCompletionProvider: monacoTypes.languages.CompletionItemProvider = {
141+
...completionProvider,
142+
provideCompletionItems: (model, position, context, token) => {
143+
// if the model-id does not match, then this call is from a different editor-instance,
144+
// not "our instance", so return nothing
145+
if (editor.getModel()?.id !== model.id) {
146+
return { suggestions: [] };
147+
}
148+
return completionProvider.provideCompletionItems(model, position, context, token);
149+
},
150+
};
151+
const { dispose } = monaco.languages.registerCompletionItemProvider(LANG_ID, filteringCompletionProvider);
152+
153+
autocompleteCleanupCallback.current = dispose;
97154
const updateElementHeight = () => {
98155
const containerDiv = containerRef.current;
99156
if (containerDiv !== null) {
@@ -110,10 +167,10 @@ const MonacoQueryField = (props: Props) => {
110167

111168
// handle: shift + enter
112169
editor.addAction({
113-
id: "execute-shift-enter",
114-
label: "Execute",
170+
id: 'execute-shift-enter',
171+
label: 'Execute',
115172
keybindings: [monaco.KeyMod.Shift | monaco.KeyCode.Enter],
116-
run: () => onRunQueryRef.current(editor.getValue() || "")
173+
run: () => onRunQueryRef.current(editor.getValue() || ''),
117174
});
118175

119176
/* Something in this configuration of monaco doesn't bubble up [mod]+K, which the
@@ -127,13 +184,8 @@ const MonacoQueryField = (props: Props) => {
127184
const placeholderDecorators = [
128185
{
129186
range: new monaco.Range(1, 1, 1, 1),
130-
contents: [
131-
{ value: "**bold** _italics_ regular `code`" }
132-
],
133-
options: {
134-
className: styles.placeholder,
135-
isWholeLine: true,
136-
},
187+
contents: [{ value: '**bold** _italics_ regular `code`' }],
188+
options: { className: styles.placeholder, isWholeLine: true },
137189
},
138190
];
139191

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { HistoryItem } from '@grafana/data';
1+
import { HistoryItem, TimeRange } from '@grafana/data';
22

3+
import { VictoriaLogsDatasource } from '../../datasource';
34
import { Query } from '../../types';
45

56
export type Props = {
67
initialValue: string;
78
history: Array<HistoryItem<Query>>;
89
placeholder: string;
910
readOnly?: boolean;
11+
timeRange?: TimeRange;
12+
datasource: VictoriaLogsDatasource;
1013
onRunQuery: (value: string) => void;
1114
onBlur: (value: string) => void;
1215
};
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { chain } from 'lodash';
2+
3+
import { HistoryItem, TimeRange } from '@grafana/data';
4+
5+
// import { escapeLabelValueInExactSelector } from '../../../languageUtils';
6+
import LanguageProvider from '../../../language_provider';
7+
import { FilterFieldType, ParserAndLabelKeysResult, Query } from '../../../types';
8+
9+
import { Label } from './situation';
10+
11+
12+
interface HistoryRef {
13+
current: Array<HistoryItem<Query>>;
14+
}
15+
16+
export class CompletionDataProvider {
17+
constructor(
18+
public languageProvider: LanguageProvider,
19+
private historyRef: HistoryRef = { current: [] },
20+
public timeRange: TimeRange | undefined
21+
) {
22+
this.queryToLabelKeysCache = new Map();
23+
}
24+
private queryToLabelKeysCache: Map<string, any>;
25+
26+
// private buildSelector(labels: Label[]): string {
27+
// const allLabelTexts = labels.map(
28+
// (label) => `${label.name}${label.op}"${escapeLabelValueInExactSelector(label.value)}"`
29+
// );
30+
31+
// return `{${allLabelTexts.join(',')}}`;
32+
// }
33+
34+
setTimeRange(timeRange: TimeRange) {
35+
this.timeRange = timeRange;
36+
this.queryToLabelKeysCache.clear();
37+
}
38+
39+
getHistory() {
40+
return chain(this.historyRef.current)
41+
.orderBy('ts', 'desc')
42+
.map((history: HistoryItem<Query>) => history.query.expr.trim())
43+
.filter()
44+
.uniq()
45+
.value();
46+
}
47+
48+
public buildQuery(otherLabels: Label[]): string {
49+
if (otherLabels.length === 0) {
50+
return '*';
51+
}
52+
return otherLabels.map(
53+
(label) => `${label.name}:=${label.value}`
54+
).join(' AND ');
55+
}
56+
57+
async getLabelNames(query: string): Promise<string[]> {
58+
const hits = await this.languageProvider.getFieldList({
59+
timeRange: this.timeRange,
60+
type: FilterFieldType.FieldName,
61+
query,
62+
})
63+
return hits
64+
.filter((hit) => !hit.value.startsWith("_"))
65+
.map((hit) => hit.value);
66+
}
67+
// if (otherLabels.length === 0) {
68+
// // If there is no filtering, we use getLabelKeys because it has better caching
69+
// // and all labels should already be fetched
70+
// await this.languageProvider.start();
71+
// return this.languageProvider.getFieldList();
72+
// }
73+
// const possibleLabelNames = await this.languageProvider.fetchLabels({
74+
// streamSelector: this.buildSelector(otherLabels),
75+
// timeRange: this.timeRange,
76+
// });
77+
// const usedLabelNames = new Set(otherLabels.map((l) => l.name)); // names used in the query
78+
// return possibleLabelNames.filter((label) => !usedLabelNames.has(label));
79+
// }
80+
81+
async getLabelValues(labelName: string, query: string): Promise<string[]> {
82+
const hits = await this.languageProvider.getFieldList({
83+
timeRange: this.timeRange,
84+
type: FilterFieldType.FieldValue,
85+
field: labelName,
86+
query,
87+
});
88+
return hits
89+
.map((hit) => hit.value);
90+
}
91+
92+
/**
93+
* Runs a Loki query to extract label keys from the result.
94+
* The result is cached for the query string.
95+
*
96+
* Since various "situations" in the monaco code editor trigger this function, it is prone to being called multiple times for the same query
97+
* Here is a lightweight and simple cache to avoid calling the backend multiple times for the same query.
98+
*
99+
* @param logQuery
100+
*/
101+
async getParserAndLabelKeys(logQuery: string): Promise<ParserAndLabelKeysResult> {
102+
const EXTRACTED_LABEL_KEYS_MAX_CACHE_SIZE = 2;
103+
const cachedLabelKeys = this.queryToLabelKeysCache.has(logQuery) ? this.queryToLabelKeysCache.get(logQuery) : null;
104+
if (cachedLabelKeys) {
105+
// cache hit! Serve stale result from cache
106+
return cachedLabelKeys;
107+
} else {
108+
// If cache is larger than max size, delete the first (oldest) index
109+
if (this.queryToLabelKeysCache.size >= EXTRACTED_LABEL_KEYS_MAX_CACHE_SIZE) {
110+
// Make room in the cache for the fresh result by deleting the "first" index
111+
const keys = this.queryToLabelKeysCache.keys();
112+
const firstKey = keys.next().value;
113+
if (firstKey !== undefined) {
114+
this.queryToLabelKeysCache.delete(firstKey);
115+
}
116+
}
117+
// Fetch a fresh result from the backend
118+
const labelKeys = await this.languageProvider.getParserAndLabelKeys(logQuery, { timeRange: this.timeRange });
119+
// Add the result to the cache
120+
this.queryToLabelKeysCache.set(logQuery, labelKeys);
121+
return labelKeys;
122+
}
123+
}
124+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export class NeverCaseError extends Error {
2+
constructor(value: never) {
3+
super(`Unexpected case in switch statement: ${JSON.stringify(value)}`);
4+
}
5+
}

0 commit comments

Comments
 (0)