Tempo - Replace slate with monaco editor in search tags field (#61168)

* Recreate the tempo search tags field with monaco editor instead of slate

* Remove test file no longer needed
This commit is contained in:
Andre Pereira 2023-01-12 12:06:28 +00:00 committed by GitHub
parent 80e7f54166
commit 62633ba4a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 574 additions and 104 deletions

View File

@ -1,33 +1,19 @@
import { css } from '@emotion/css';
import Prism from 'prismjs';
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { Node } from 'slate';
import { GrafanaTheme2, isValidGoDuration, SelectableValue, toOption } from '@grafana/data';
import { FetchError, getTemplateSrv, isFetchError, TemplateSrv } from '@grafana/runtime';
import {
InlineFieldRow,
InlineField,
Input,
QueryField,
SlatePrism,
BracesPlugin,
TypeaheadInput,
TypeaheadOutput,
Alert,
useStyles2,
fuzzyMatch,
Select,
} from '@grafana/ui';
import { InlineFieldRow, InlineField, Input, Alert, useStyles2, fuzzyMatch, Select } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { dispatch } from 'app/store/store';
import { DEFAULT_LIMIT, TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { tokenizer } from '../syntax';
import { TempoQuery } from '../types';
import { TagsField } from './TagsField/TagsField';
interface Props {
datasource: TempoDatasource;
query: TempoQuery;
@ -36,22 +22,11 @@ interface Props {
onRunQuery: () => void;
}
const PRISM_LANGUAGE = 'tempo';
const durationPlaceholder = 'e.g. 1.2s, 100ms';
const plugins = [
BracesPlugin(),
SlatePrism({
onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block',
getSyntax: () => PRISM_LANGUAGE,
}),
];
Prism.languages[PRISM_LANGUAGE] = tokenizer;
const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props) => {
const styles = useStyles2(getStyles);
const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]);
const [hasSyntaxLoaded, setHasSyntaxLoaded] = useState(false);
const [serviceOptions, setServiceOptions] = useState<Array<SelectableValue<string>>>();
const [spanOptions, setSpanOptions] = useState<Array<SelectableValue<string>>>();
const [error, setError] = useState<Error | FetchError | null>(null);
@ -111,35 +86,6 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
fetchOptions();
}, [languageProvider, loadOptions, query.serviceName, query.spanName]);
useEffect(() => {
const fetchTags = async () => {
try {
await languageProvider.start();
setHasSyntaxLoaded(true);
} catch (error) {
if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
}
}
};
fetchTags();
}, [languageProvider]);
const onTypeahead = useCallback(
async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
return await languageProvider.provideCompletionItems(typeahead);
},
[languageProvider]
);
const cleanText = useCallback((text: string) => {
const splittedText = text.split(/\s+(?=([^"]*"[^"]*")*[^"]*$)/g);
if (splittedText.length > 1) {
return splittedText[splittedText.length - 1];
}
return text;
}, []);
const onKeyDown = (keyEvent: React.KeyboardEvent) => {
if (keyEvent.key === 'Enter' && (keyEvent.shiftKey || keyEvent.ctrlKey)) {
onRunQuery();
@ -219,17 +165,12 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props
</InlineFieldRow>
<InlineFieldRow>
<InlineField label="Tags" labelWidth={14} grow tooltip="Values should be in logfmt.">
<QueryField
additionalPlugins={plugins}
query={query.search}
onTypeahead={onTypeahead}
onBlur={onBlur}
onChange={handleOnChange}
cleanText={cleanText}
<TagsField
placeholder="http.status_code=200 error=true"
onRunQuery={onRunQuery}
syntaxLoaded={hasSyntaxLoaded}
portalOrigin="tempo"
value={query.search || ''}
onChange={handleOnChange}
onBlur={onBlur}
datasource={datasource}
/>
</InlineField>
</InlineFieldRow>

View File

@ -0,0 +1,187 @@
import { css } from '@emotion/css';
import React, { useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui';
import { createErrorNotification } from '../../../../../core/copy/appNotification';
import { notifyApp } from '../../../../../core/reducers/appNotification';
import { dispatch } from '../../../../../store/store';
import { TempoDatasource } from '../../datasource';
import { CompletionProvider } from './autocomplete';
import { languageDefinition } from './syntax';
interface Props {
placeholder: string;
value: string;
onChange: (val: string) => void;
onBlur?: () => void;
datasource: TempoDatasource;
}
export function TagsField(props: Props) {
const { onChange, onBlur, placeholder } = props;
const setupAutocompleteFn = useAutocomplete(props.datasource);
const theme = useTheme2();
const styles = getStyles(theme, placeholder);
return (
<CodeEditor
value={props.value}
language={langId}
onBlur={onBlur}
onChange={onChange}
containerStyles={styles.queryField}
monacoOptions={{
folding: false,
fontSize: 14,
lineNumbers: 'off',
overviewRulerLanes: 0,
renderLineHighlight: 'none',
scrollbar: {
vertical: 'hidden',
verticalScrollbarSize: 8, // used as "padding-right"
horizontal: 'hidden',
horizontalScrollbarSize: 0,
},
scrollBeyondLastLine: false,
wordWrap: 'on',
}}
onBeforeEditorMount={ensureTraceQL}
onEditorDidMount={(editor, monaco) => {
setupAutocompleteFn(editor, monaco);
setupPlaceholder(editor, monaco, styles);
setupAutoSize(editor);
}}
/>
);
}
function setupPlaceholder(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco, styles: EditorStyles) {
const placeholderDecorators = [
{
range: new monaco.Range(1, 1, 1, 1),
options: {
className: styles.placeholder, // The placeholder text is in styles.placeholder
isWholeLine: true,
},
},
];
let decorators: string[] = [];
const checkDecorators = (): void => {
const model = editor.getModel();
if (!model) {
return;
}
const newDecorators = model.getValueLength() === 0 ? placeholderDecorators : [];
decorators = model.deltaDecorations(decorators, newDecorators);
};
checkDecorators();
editor.onDidChangeModelContent(checkDecorators);
}
function setupAutoSize(editor: monacoTypes.editor.IStandaloneCodeEditor) {
const container = editor.getDomNode();
const updateHeight = () => {
if (container) {
const contentHeight = Math.min(1000, editor.getContentHeight());
const width = parseInt(container.style.width, 10);
container.style.width = `${width}px`;
container.style.height = `${contentHeight}px`;
editor.layout({ width, height: contentHeight });
}
};
editor.onDidContentSizeChange(updateHeight);
updateHeight();
}
/**
* Hook that returns function that will set up monaco autocomplete for the label selector
* @param datasource
*/
function useAutocomplete(datasource: TempoDatasource) {
// We need the provider ref so we can pass it the label/values data later. This is because we run the call for the
// values here but there is additional setup needed for the provider later on. We could run the getSeries() in the
// returned function but that is run after the monaco is mounted so would delay the request a bit when it does not
// need to.
const providerRef = useRef<CompletionProvider>(
new CompletionProvider({ languageProvider: datasource.languageProvider })
);
useEffect(() => {
const fetchTags = async () => {
try {
await datasource.languageProvider.start();
const tags = datasource.languageProvider.getTags();
if (tags) {
providerRef.current.setTags(tags);
}
} catch (error) {
if (error instanceof Error) {
dispatch(notifyApp(createErrorNotification('Error', error)));
}
}
};
fetchTags();
}, [datasource]);
const autocompleteDisposeFun = useRef<(() => void) | null>(null);
useEffect(() => {
// when we unmount, we unregister the autocomplete-function, if it was registered
return () => {
autocompleteDisposeFun.current?.();
};
}, []);
// This should be run in monaco onEditorDidMount
return (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => {
providerRef.current.editor = editor;
providerRef.current.monaco = monaco;
const { dispose } = monaco.languages.registerCompletionItemProvider(langId, providerRef.current);
autocompleteDisposeFun.current = dispose;
};
}
// we must only run the setup code once
let setupDone = false;
const langId = 'tagsfield';
function ensureTraceQL(monaco: Monaco) {
if (!setupDone) {
setupDone = true;
const { aliases, extensions, mimetypes, def } = languageDefinition;
monaco.languages.register({ id: langId, aliases, extensions, mimetypes });
monaco.languages.setMonarchTokensProvider(langId, def.language);
monaco.languages.setLanguageConfiguration(langId, def.languageConfiguration);
}
}
interface EditorStyles {
placeholder: string;
queryField: string;
}
const getStyles = (theme: GrafanaTheme2, placeholder: string): EditorStyles => {
return {
queryField: css`
border-radius: ${theme.shape.borderRadius()};
border: 1px solid ${theme.components.input.borderColor};
flex: 1;
`,
placeholder: css`
::after {
content: '${placeholder}';
font-family: ${theme.typography.fontFamilyMonospace};
opacity: 0.3;
}
`,
};
};

View File

@ -0,0 +1,255 @@
import { SelectableValue } from '@grafana/data';
import type { Monaco, monacoTypes } from '@grafana/ui';
import TempoLanguageProvider from '../../language_provider';
interface Props {
languageProvider: TempoLanguageProvider;
}
/**
* Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco
* autocomplete system.
*/
export class CompletionProvider implements monacoTypes.languages.CompletionItemProvider {
languageProvider: TempoLanguageProvider;
constructor(props: Props) {
this.languageProvider = props.languageProvider;
}
triggerCharacters = ['=', ' '];
// We set these directly and ae required for the provider to function.
monaco: Monaco | undefined;
editor: monacoTypes.editor.IStandaloneCodeEditor | undefined;
private tags: { [tag: string]: Set<string> } = {};
private cachedValues: { [key: string]: Array<SelectableValue<string>> } = {};
provideCompletionItems(
model: monacoTypes.editor.ITextModel,
position: monacoTypes.Position
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> {
// Should not happen, this should not be called before it is initialized
if (!(this.monaco && this.editor)) {
throw new Error('provideCompletionItems called before CompletionProvider was initialized');
}
// if the model-id does not match, then this call is from a different editor-instance,
// not "our instance", so return nothing
if (this.editor.getModel()?.id !== model.id) {
return { suggestions: [] };
}
const { range, offset } = getRangeAndOffset(this.monaco, model, position);
const situation = this.getSituation(model.getValue(), offset);
const completionItems = this.getCompletions(situation);
return completionItems.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: monacoTypes.languages.CompletionItem[] = items.map((item, index) => {
const suggestion: monacoTypes.languages.CompletionItem = {
kind: getMonacoCompletionItemKind(item.type, this.monaco!),
label: item.label,
insertText: item.insertText,
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
range,
};
return suggestion;
});
return { suggestions };
});
}
/**
* We expect the tags list data directly from the request and assign it an empty set here.
*/
setTags(tags: string[]) {
tags.forEach((t) => (this.tags[t] = new Set<string>()));
}
private async getTagValues(tagName: string): Promise<Array<SelectableValue<string>>> {
let tagValues: Array<SelectableValue<string>>;
if (this.cachedValues.hasOwnProperty(tagName)) {
tagValues = this.cachedValues[tagName];
} else {
tagValues = await this.languageProvider.getOptions(tagName);
this.cachedValues[tagName] = tagValues;
}
return tagValues;
}
/**
* Get suggestion based on the situation we are in like whether we should suggest tag names or values.
* @param situation
* @private
*/
private async getCompletions(situation: Situation): Promise<Completion[]> {
if (!Object.keys(this.tags).length) {
return [];
}
switch (situation.type) {
// Not really sure what would make sense to suggest in this case so just leave it
case 'UNKNOWN': {
return [];
}
case 'EMPTY': {
return this.getTagsCompletions();
}
case 'IN_NAME':
return this.getTagsCompletions();
case 'IN_VALUE':
const tagValues = await this.getTagValues(situation.tagName);
const items: Completion[] = [];
const getInsertionText = (val: SelectableValue<string>): string => `"${val.label}"`;
tagValues.forEach((val) => {
if (val?.label) {
items.push({
label: val.label,
insertText: getInsertionText(val),
type: 'TAG_VALUE',
});
}
});
return items;
default:
throw new Error(`Unexpected situation ${situation}`);
}
}
private getTagsCompletions(): Completion[] {
return Object.keys(this.tags)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'accent' }))
.map((key) => ({
label: key,
insertText: key,
type: 'TAG_NAME',
}));
}
/**
* Figure out where is the cursor and what kind of suggestions are appropriate.
* @param text
* @param offset
*/
private getSituation(text: string, offset: number): Situation {
if (text === '' || offset === 0 || text[text.length - 1] === ' ') {
return {
type: 'EMPTY',
};
}
const textUntilCaret = text.substring(0, offset);
const regex = /(?<key>[^= ]+)(?<equals>=)?(?<value>([^ "]+)|"([^"]*)")?/;
const matches = textUntilCaret.match(new RegExp(regex, 'g'));
if (matches?.length) {
const last = matches[matches.length - 1];
const lastMatched = last.match(regex);
if (lastMatched) {
const key = lastMatched.groups?.key;
const equals = lastMatched.groups?.equals;
if (!key) {
return {
type: 'EMPTY',
};
}
if (!equals) {
return {
type: 'IN_NAME',
};
}
return {
type: 'IN_VALUE',
tagName: key,
};
}
}
return {
type: 'EMPTY',
};
}
}
/**
* Get item kind which is used for icon next to the suggestion.
* @param type
* @param monaco
*/
function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind {
switch (type) {
case 'TAG_NAME':
return monaco.languages.CompletionItemKind.Enum;
case 'KEYWORD':
return monaco.languages.CompletionItemKind.Keyword;
case 'OPERATOR':
return monaco.languages.CompletionItemKind.Operator;
case 'TAG_VALUE':
return monaco.languages.CompletionItemKind.EnumMember;
case 'SCOPE':
return monaco.languages.CompletionItemKind.Class;
default:
throw new Error(`Unexpected CompletionType: ${type}`);
}
}
export type CompletionType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE';
type Completion = {
type: CompletionType;
label: string;
insertText: string;
};
export type Tag = {
name: string;
value: string;
};
export type Situation =
| {
type: 'UNKNOWN';
}
| {
type: 'EMPTY';
}
| {
type: 'IN_NAME';
}
| {
type: 'IN_VALUE';
tagName: string;
};
function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel, position: monacoTypes.Position) {
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` so we clone it here just for sure.
const positionClone = {
column: position.column,
lineNumber: position.lineNumber,
};
const offset = model.getOffsetAt(positionClone);
return { offset, range };
}

View File

@ -0,0 +1,124 @@
import type { languages } from 'monaco-editor';
export const languageConfiguration: languages.LanguageConfiguration = {
// the default separators except `@$`
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g,
brackets: [
['{', '}'],
['(', ')'],
],
autoClosingPairs: [
{ open: '{', close: '}' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
surroundingPairs: [
{ open: '{', close: '}' },
{ open: '(', close: ')' },
{ open: '"', close: '"' },
{ open: "'", close: "'" },
],
folding: {},
};
const operators = ['='];
export const language: languages.IMonarchLanguage = {
ignoreCase: false,
defaultToken: '',
tokenPostfix: '.tagsfield',
operators,
// we include these common regular expressions
symbols: /[=><!~?:&|+\-*\/^%]+/,
escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
digits: /\d+(_+\d+)*/,
octaldigits: /[0-7]+(_+[0-7]+)*/,
binarydigits: /[0-1]+(_+[0-1]+)*/,
hexdigits: /[[0-9a-fA-F]+(_+[0-9a-fA-F]+)*/,
integersuffix: /(ll|LL|u|U|l|L)?(ll|LL|u|U|l|L)?/,
floatsuffix: /[fFlL]?/,
tokenizer: {
root: [
// labels
[/[a-z_.][\w./_-]*(?=\s*(=|!=|>|<|>=|<=|=~|!~))/, 'tag'],
// all keywords have the same color
[
/[a-zA-Z_.]\w*/,
{
cases: {
'@default': 'identifier',
},
},
],
// strings
[/"([^"\\]|\\.)*$/, 'string.invalid'], // non-teminated string
[/'([^'\\]|\\.)*$/, 'string.invalid'], // non-teminated string
[/"/, 'string', '@string_double'],
[/'/, 'string', '@string_single'],
// whitespace
{ include: '@whitespace' },
// delimiters and operators
[/[{}()\[\]]/, '@brackets'],
[/[<>](?!@symbols)/, '@brackets'],
[
/@symbols/,
{
cases: {
'@operators': 'delimiter',
'@default': '',
},
},
],
// numbers
[/\d+/, 'number'],
[/\d*\d+[eE]([\-+]?\d+)?(@floatsuffix)/, 'number.float'],
[/\d*\.\d+([eE][\-+]?\d+)?(@floatsuffix)/, 'number.float'],
[/0[xX][0-9a-fA-F']*[0-9a-fA-F](@integersuffix)/, 'number.hex'],
[/0[0-7']*[0-7](@integersuffix)/, 'number.octal'],
[/0[bB][0-1']*[0-1](@integersuffix)/, 'number.binary'],
[/\d[\d']*\d(@integersuffix)/, 'number'],
[/\d(@integersuffix)/, 'number'],
],
string_double: [
[/[^\\"]+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/"/, 'string', '@pop'],
],
string_single: [
[/[^\\']+/, 'string'],
[/@escapes/, 'string.escape'],
[/\\./, 'string.escape.invalid'],
[/'/, 'string', '@pop'],
],
clauses: [
[/[^(,)]/, 'tag'],
[/\)/, 'identifier', '@pop'],
],
whitespace: [[/[ \t\r\n]+/, 'white']],
},
};
export const languageDefinition = {
id: 'tagsfield',
extensions: ['.tagsfield'],
aliases: ['tagsfield'],
mimetypes: [],
def: {
language,
languageConfiguration,
},
};

View File

@ -1,20 +0,0 @@
import Prism from 'prismjs';
import { tokenizer } from './syntax';
describe('Loki syntax', () => {
it('should highlight Loki query correctly', () => {
expect(Prism.highlight('key=value', tokenizer, 'tempo')).toBe(
'<span class="token key attr-name">key</span><span class="token operator">=</span><span class="token value">value</span>'
);
expect(Prism.highlight('root.ip=172.123.0.1', tokenizer, 'tempo')).toBe(
'<span class="token key attr-name">root.ip</span><span class="token operator">=</span><span class="token value">172.123.0.1</span>'
);
expect(Prism.highlight('root.name="http get /config"', tokenizer, 'tempo')).toBe(
'<span class="token key attr-name">root.name</span><span class="token operator">=</span><span class="token value">"http get /config"</span>'
);
expect(Prism.highlight('key=value key2=value2', tokenizer, 'tempo')).toBe(
'<span class="token key attr-name">key</span><span class="token operator">=</span><span class="token value">value</span> <span class="token key attr-name">key2</span><span class="token operator">=</span><span class="token value">value2</span>'
);
});
});

View File

@ -1,17 +0,0 @@
import { Grammar } from 'prismjs';
export const tokenizer: Grammar = {
key: {
pattern: /[^\s]+(?==)/,
alias: 'attr-name',
},
operator: /[=]/,
value: [
{
pattern: /"(.+)"/,
},
{
pattern: /[^\s]+/,
},
],
};