mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Tempo: Basic PoC of the upcoming TraceQL editor in Explore (#54028)
* First working version of the TraceQL editor with syntax highlighting and autocomplete * Add feature flag around the new editor option * Fix tests and cleanup * Fix misspelling
This commit is contained in:
parent
94b4f6f459
commit
c8f2148f75
@ -63,4 +63,5 @@ export interface FeatureToggles {
|
||||
internationalization?: boolean;
|
||||
topnav?: boolean;
|
||||
customBranding?: boolean;
|
||||
traceqlEditor?: boolean;
|
||||
}
|
||||
|
@ -262,5 +262,10 @@ var (
|
||||
Description: "Replaces whitelabeling with the new custom branding feature",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
{
|
||||
Name: "traceqlEditor",
|
||||
Description: "Show the TraceQL editor in the explore page",
|
||||
State: FeatureStateAlpha,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
@ -194,4 +194,8 @@ const (
|
||||
// FlagCustomBranding
|
||||
// Replaces whitelabeling with the new custom branding feature
|
||||
FlagCustomBranding = "customBranding"
|
||||
|
||||
// FlagTraceqlEditor
|
||||
// Show the TraceQL editor in the explore page
|
||||
FlagTraceqlEditor = "traceqlEditor"
|
||||
)
|
||||
|
@ -3,7 +3,7 @@ import React from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
|
||||
import { QueryEditorProps, SelectableValue } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import {
|
||||
FileDropzone,
|
||||
InlineField,
|
||||
@ -19,6 +19,7 @@ import { LokiQueryField } from '../../loki/components/LokiQueryField';
|
||||
import { LokiDatasource } from '../../loki/datasource';
|
||||
import { LokiQuery } from '../../loki/types';
|
||||
import { TempoDatasource, TempoQuery, TempoQueryType } from '../datasource';
|
||||
import { QueryEditor } from '../traceql/QueryEditor';
|
||||
|
||||
import NativeSearch from './NativeSearch';
|
||||
import { ServiceGraphSection } from './ServiceGraphSection';
|
||||
@ -95,6 +96,10 @@ class TempoQueryFieldComponent extends React.PureComponent<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.featureToggles.traceqlEditor) {
|
||||
queryTypeOptions.push({ value: 'traceql', label: 'TraceQL' });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InlineFieldRow>
|
||||
@ -173,6 +178,14 @@ class TempoQueryFieldComponent extends React.PureComponent<Props> {
|
||||
{query.queryType === 'serviceMap' && (
|
||||
<ServiceGraphSection graphDatasourceUid={graphDatasourceUid} query={query} onChange={onChange} />
|
||||
)}
|
||||
{query.queryType === 'traceql' && (
|
||||
<QueryEditor
|
||||
datasource={this.props.datasource}
|
||||
query={query}
|
||||
onRunQuery={this.props.onRunQuery}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -45,6 +45,7 @@ import {
|
||||
errorRateMetric,
|
||||
defaultTableFilter,
|
||||
} from './graphTransform';
|
||||
import TempoLanguageProvider from './language_provider';
|
||||
import {
|
||||
transformTrace,
|
||||
transformTraceList,
|
||||
@ -53,7 +54,7 @@ import {
|
||||
} from './resultTransformer';
|
||||
|
||||
// search = Loki search, nativeSearch = Tempo search for backwards compatibility
|
||||
export type TempoQueryType = 'search' | 'traceId' | 'serviceMap' | 'upload' | 'nativeSearch' | 'clear';
|
||||
export type TempoQueryType = 'traceql' | 'search' | 'traceId' | 'serviceMap' | 'upload' | 'nativeSearch' | 'clear';
|
||||
|
||||
export interface TempoJsonData extends DataSourceJsonData {
|
||||
tracesToLogs?: TraceToLogsOptions;
|
||||
@ -111,6 +112,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
};
|
||||
uploadedJson?: string | ArrayBuffer | null = null;
|
||||
spanBar?: SpanBarOptions;
|
||||
languageProvider: TempoLanguageProvider;
|
||||
|
||||
constructor(
|
||||
private instanceSettings: DataSourceInstanceSettings<TempoJsonData>,
|
||||
@ -122,6 +124,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
|
||||
this.search = instanceSettings.jsonData.search;
|
||||
this.nodeGraph = instanceSettings.jsonData.nodeGraph;
|
||||
this.lokiSearch = instanceSettings.jsonData.lokiSearch;
|
||||
this.languageProvider = new TempoLanguageProvider(this);
|
||||
}
|
||||
|
||||
query(options: DataQueryRequest<TempoQuery>): Observable<DataQueryResponse> {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Value } from 'slate';
|
||||
|
||||
import { HistoryItem, LanguageProvider, SelectableValue } from '@grafana/data';
|
||||
import { LanguageProvider, SelectableValue } from '@grafana/data';
|
||||
import { CompletionItemGroup, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
|
||||
|
||||
import { TempoDatasource } from './datasource';
|
||||
@ -21,8 +21,13 @@ export default class TempoLanguageProvider extends LanguageProvider {
|
||||
};
|
||||
|
||||
start = async () => {
|
||||
await this.fetchTags();
|
||||
return [];
|
||||
if (!this.startTask) {
|
||||
this.startTask = this.fetchTags().then(() => {
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
return this.startTask;
|
||||
};
|
||||
|
||||
async fetchTags() {
|
||||
@ -30,10 +35,11 @@ export default class TempoLanguageProvider extends LanguageProvider {
|
||||
this.tags = response.tagNames;
|
||||
}
|
||||
|
||||
provideCompletionItems = async (
|
||||
{ prefix, text, value, labelKey, wrapperClasses }: TypeaheadInput,
|
||||
context: { history: Array<HistoryItem<any>> } = { history: [] }
|
||||
): Promise<TypeaheadOutput> => {
|
||||
getTags = () => {
|
||||
return this.tags;
|
||||
};
|
||||
|
||||
provideCompletionItems = async ({ text, value }: TypeaheadInput): Promise<TypeaheadOutput> => {
|
||||
const emptyResult: TypeaheadOutput = { suggestions: [] };
|
||||
|
||||
if (!value) {
|
||||
|
25
public/app/plugins/datasource/tempo/traceql/QueryEditor.tsx
Normal file
25
public/app/plugins/datasource/tempo/traceql/QueryEditor.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { defaults } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { QueryEditorProps } from '@grafana/data';
|
||||
|
||||
import { TempoDatasource, TempoQuery } from '../datasource';
|
||||
import { defaultQuery, MyDataSourceOptions } from '../types';
|
||||
|
||||
import { TraceQLEditor } from './TraceQLEditor';
|
||||
|
||||
type Props = QueryEditorProps<TempoDatasource, TempoQuery, MyDataSourceOptions>;
|
||||
|
||||
export function QueryEditor(props: Props) {
|
||||
function onEditorChange(value: string) {
|
||||
props.onChange({ ...props.query, query: value });
|
||||
}
|
||||
|
||||
let query = defaults(props.query, defaultQuery);
|
||||
|
||||
return (
|
||||
<div className="gf-form">
|
||||
<TraceQLEditor value={query.query} onChange={onEditorChange} datasource={props.datasource} />
|
||||
</div>
|
||||
);
|
||||
}
|
126
public/app/plugins/datasource/tempo/traceql/TraceQLEditor.tsx
Normal file
126
public/app/plugins/datasource/tempo/traceql/TraceQLEditor.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { css } from '@emotion/css';
|
||||
import type { languages } from 'monaco-editor';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { CodeEditor, Monaco, useStyles2, monacoTypes } 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 './traceql';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
datasource: TempoDatasource;
|
||||
}
|
||||
|
||||
export function TraceQLEditor(props: Props) {
|
||||
const setupAutocompleteFn = useAutocomplete(props.datasource);
|
||||
const styles = useStyles2(getStyles);
|
||||
return (
|
||||
<CodeEditor
|
||||
value={props.value}
|
||||
language={langId}
|
||||
onBlur={props.onChange}
|
||||
height={'30px'}
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 traceqlSetupDone = false;
|
||||
const langId = 'traceql';
|
||||
|
||||
function ensureTraceQL(monaco: Monaco) {
|
||||
if (!traceqlSetupDone) {
|
||||
traceqlSetupDone = true;
|
||||
const { aliases, extensions, mimetypes, def } = languageDefinition;
|
||||
monaco.languages.register({ id: langId, aliases, extensions, mimetypes });
|
||||
monaco.languages.setMonarchTokensProvider(langId, def.language as languages.IMonarchLanguage);
|
||||
monaco.languages.setLanguageConfiguration(langId, def.languageConfiguration as languages.LanguageConfiguration);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
queryField: css`
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
border: 1px solid ${theme.components.input.borderColor};
|
||||
flex: 1;
|
||||
`,
|
||||
};
|
||||
};
|
147
public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts
Normal file
147
public/app/plugins/datasource/tempo/traceql/autocomplete.test.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { DataSourceInstanceSettings, PluginType } from '@grafana/data/src';
|
||||
import { monacoTypes } from '@grafana/ui';
|
||||
|
||||
import { TempoDatasource, TempoJsonData } from '../datasource';
|
||||
import TempoLanguageProvider from '../language_provider';
|
||||
|
||||
import { CompletionProvider } from './autocomplete';
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
reportInteraction: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('CompletionProvider', () => {
|
||||
it('suggests tags', async () => {
|
||||
const { provider, model } = setup('{}', 1, defaultTags);
|
||||
const result = await provider.provideCompletionItems(model as any, {} as any);
|
||||
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
|
||||
expect.objectContaining({ label: 'foo', insertText: 'foo' }),
|
||||
expect.objectContaining({ label: 'bar', insertText: 'bar' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('suggests tag names with quotes', async () => {
|
||||
const { provider, model } = setup('{foo=}', 6, defaultTags);
|
||||
|
||||
jest.spyOn(provider.languageProvider, 'getOptions').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolve([
|
||||
{
|
||||
value: 'foobar',
|
||||
label: 'foobar',
|
||||
},
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
const result = await provider.provideCompletionItems(model as any, {} as any);
|
||||
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
|
||||
expect.objectContaining({ label: 'foobar', insertText: '"foobar"' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('suggests tag names without quotes', async () => {
|
||||
const { provider, model } = setup('{foo="}', 7, defaultTags);
|
||||
|
||||
jest.spyOn(provider.languageProvider, 'getOptions').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolve([
|
||||
{
|
||||
value: 'foobar',
|
||||
label: 'foobar',
|
||||
},
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
const result = await provider.provideCompletionItems(model as any, {} as any);
|
||||
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
|
||||
expect.objectContaining({ label: 'foobar', insertText: 'foobar' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('suggests nothing without tags', async () => {
|
||||
const { provider, model } = setup('{foo="}', 7, []);
|
||||
const result = await provider.provideCompletionItems(model as any, {} as any);
|
||||
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('suggests tags on empty input', async () => {
|
||||
const { provider, model } = setup('', 0, defaultTags);
|
||||
const result = await provider.provideCompletionItems(model as any, {} as any);
|
||||
expect((result! as monacoTypes.languages.CompletionList).suggestions).toEqual([
|
||||
expect.objectContaining({ label: 'foo', insertText: '{foo="' }),
|
||||
expect.objectContaining({ label: 'bar', insertText: '{bar="' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const defaultTags = ['foo', 'bar'];
|
||||
|
||||
function setup(value: string, offset: number, tags?: string[]) {
|
||||
const ds = new TempoDatasource(defaultSettings);
|
||||
const provider = new CompletionProvider({ languageProvider: new TempoLanguageProvider(ds) });
|
||||
if (tags) {
|
||||
provider.setTags(tags);
|
||||
}
|
||||
const model = makeModel(value, offset);
|
||||
provider.monaco = {
|
||||
Range: {
|
||||
fromPositions() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
languages: {
|
||||
CompletionItemKind: {
|
||||
Enum: 1,
|
||||
EnumMember: 2,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
provider.editor = {
|
||||
getModel() {
|
||||
return model;
|
||||
},
|
||||
} as any;
|
||||
|
||||
return { provider, model };
|
||||
}
|
||||
|
||||
function makeModel(value: string, offset: number) {
|
||||
return {
|
||||
id: 'test_monaco',
|
||||
getWordAtPosition() {
|
||||
return null;
|
||||
},
|
||||
getOffsetAt() {
|
||||
return offset;
|
||||
},
|
||||
getValue() {
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const defaultSettings: DataSourceInstanceSettings<TempoJsonData> = {
|
||||
id: 0,
|
||||
uid: 'gdev-tempo',
|
||||
type: 'tracing',
|
||||
name: 'tempo',
|
||||
access: 'proxy',
|
||||
meta: {
|
||||
id: 'tempo',
|
||||
name: 'tempo',
|
||||
type: PluginType.datasource,
|
||||
info: {} as any,
|
||||
module: '',
|
||||
baseUrl: '',
|
||||
},
|
||||
jsonData: {
|
||||
nodeGraph: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
235
public/app/plugins/datasource/tempo/traceql/autocomplete.ts
Normal file
235
public/app/plugins/datasource/tempo/traceql/autocomplete.ts
Normal file
@ -0,0 +1,235 @@
|
||||
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> } = {};
|
||||
|
||||
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 = 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) => ({
|
||||
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 { 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>()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Object.keys(this.tags).map((key) => {
|
||||
return {
|
||||
label: key,
|
||||
insertText: `{${key}="`,
|
||||
type: 'TAG_NAME',
|
||||
};
|
||||
});
|
||||
}
|
||||
case 'IN_TAG_NAME':
|
||||
return Object.keys(this.tags).map((key) => {
|
||||
return {
|
||||
label: key,
|
||||
insertText: key,
|
||||
type: 'TAG_NAME',
|
||||
};
|
||||
});
|
||||
case 'IN_TAG_VALUE':
|
||||
return await this.languageProvider.getOptions(situation.tagName).then((res) => {
|
||||
const items: Completion[] = [];
|
||||
res.forEach((val) => {
|
||||
if (val?.label) {
|
||||
items.push({
|
||||
label: val.label,
|
||||
insertText: situation.betweenQuotes ? val.label : `"${val.label}"`,
|
||||
type: 'TAG_VALUE',
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
});
|
||||
default:
|
||||
throw new Error(`Unexpected situation ${situation}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 'TAG_VALUE':
|
||||
return monaco.languages.CompletionItemKind.EnumMember;
|
||||
default:
|
||||
throw new Error(`Unexpected CompletionType: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type CompletionType = 'TAG_NAME' | 'TAG_VALUE';
|
||||
type Completion = {
|
||||
type: CompletionType;
|
||||
label: string;
|
||||
insertText: string;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Situation =
|
||||
| {
|
||||
type: 'UNKNOWN';
|
||||
}
|
||||
| {
|
||||
type: 'EMPTY';
|
||||
}
|
||||
| {
|
||||
type: 'IN_TAG_NAME';
|
||||
otherTags: Tag[];
|
||||
}
|
||||
| {
|
||||
type: 'IN_TAG_VALUE';
|
||||
tagName: string;
|
||||
betweenQuotes: boolean;
|
||||
otherTags: Tag[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Figure out where is the cursor and what kind of suggestions are appropriate.
|
||||
* As currently TraceQL handles just a simple {foo="bar", baz="zyx"} kind of values we can do with simple regex to figure
|
||||
* out where we are with the cursor.
|
||||
* @param text
|
||||
* @param offset
|
||||
*/
|
||||
function getSituation(text: string, offset: number): Situation {
|
||||
if (text === '') {
|
||||
return {
|
||||
type: 'EMPTY',
|
||||
};
|
||||
}
|
||||
|
||||
// Get all the tags so far in the query so we can do some more filtering.
|
||||
const matches = text.matchAll(/(\w+)="(\w+)"/g);
|
||||
const existingTags = Array.from(matches).reduce((acc, match) => {
|
||||
const [_, name, value] = match[1];
|
||||
acc.push({ name, value });
|
||||
return acc;
|
||||
}, [] as Tag[]);
|
||||
|
||||
// Check if we are editing a tag value right now. If so also get name of the tag
|
||||
const matchTagValue = text.substring(0, offset).match(/([\w.]+)=("?)[^"]*$/);
|
||||
if (matchTagValue) {
|
||||
return {
|
||||
type: 'IN_TAG_VALUE',
|
||||
tagName: matchTagValue[1],
|
||||
betweenQuotes: !!matchTagValue[2],
|
||||
otherTags: existingTags,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we are editing a tag name
|
||||
const matchTagName = text.substring(0, offset).match(/[{,]\s*[^"]*$/);
|
||||
if (matchTagName) {
|
||||
return {
|
||||
type: 'IN_TAG_NAME',
|
||||
otherTags: existingTags,
|
||||
};
|
||||
}
|
||||
|
||||
// Will happen only if user writes something that isn't really a tag selector
|
||||
return {
|
||||
type: 'UNKNOWN',
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
106
public/app/plugins/datasource/tempo/traceql/traceql.ts
Normal file
106
public/app/plugins/datasource/tempo/traceql/traceql.ts
Normal file
@ -0,0 +1,106 @@
|
||||
export const languageConfiguration = {
|
||||
// the default separators except `@$`
|
||||
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g,
|
||||
brackets: [['{', '}']],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
],
|
||||
folding: {},
|
||||
};
|
||||
|
||||
export const language = {
|
||||
ignoreCase: false,
|
||||
defaultToken: '',
|
||||
tokenPostfix: '.traceql',
|
||||
|
||||
keywords: [],
|
||||
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'],
|
||||
|
||||
// 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: 'traceql',
|
||||
extensions: ['.traceql'],
|
||||
aliases: ['tempo', 'traceql'],
|
||||
mimetypes: [],
|
||||
def: {
|
||||
language,
|
||||
languageConfiguration,
|
||||
},
|
||||
};
|
7
public/app/plugins/datasource/tempo/types.ts
Normal file
7
public/app/plugins/datasource/tempo/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { DataSourceJsonData } from '@grafana/data/src';
|
||||
|
||||
import { TempoQuery } from './datasource';
|
||||
|
||||
export interface MyDataSourceOptions extends DataSourceJsonData {}
|
||||
|
||||
export const defaultQuery: Partial<TempoQuery> = {};
|
Loading…
Reference in New Issue
Block a user