mirror of
https://github.com/grafana/grafana.git
synced 2025-02-16 18:34:52 -06:00
* Performance: Standardize lodash imports to use destructured members Changes lodash imports of the form `import x from 'lodash/x'` to `import { x } from 'lodash'` to reduce bundle size. * Remove unnecessary _ import from Graph component * Enforce lodash import style * Fix remaining lodash imports
446 lines
13 KiB
TypeScript
446 lines
13 KiB
TypeScript
import { debounce, map } from 'lodash';
|
|
import Plain from 'slate-plain-serializer';
|
|
|
|
import QueryField from './query_field';
|
|
import { DOMUtil } from '@grafana/ui';
|
|
import { Editor as CoreEditor } from 'slate';
|
|
|
|
import { KEYWORDS, functionTokens, operatorTokens, grafanaMacros } from './kusto/kusto';
|
|
// import '../sass/editor.base.scss';
|
|
|
|
const TYPEAHEAD_DELAY = 100;
|
|
|
|
interface Suggestion {
|
|
text: string;
|
|
deleteBackwards?: number;
|
|
type?: string;
|
|
}
|
|
|
|
interface SuggestionGroup {
|
|
label: string;
|
|
items: Suggestion[];
|
|
prefixMatch?: boolean;
|
|
skipFilter?: boolean;
|
|
}
|
|
|
|
interface KustoSchema {
|
|
Databases: {
|
|
Default: KustoDBSchema;
|
|
};
|
|
Plugins?: any[];
|
|
}
|
|
|
|
interface KustoDBSchema {
|
|
Name?: string;
|
|
Functions?: any;
|
|
Tables?: any;
|
|
}
|
|
|
|
const defaultSchema: any = () => ({
|
|
Databases: {
|
|
Default: {},
|
|
},
|
|
});
|
|
|
|
const cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
|
|
const wrapText = (text: string) => ({ text });
|
|
|
|
export default class KustoQueryField extends QueryField {
|
|
fields: any;
|
|
events: any;
|
|
schema: KustoSchema;
|
|
|
|
constructor(props: any, context: any) {
|
|
super(props, context);
|
|
this.schema = defaultSchema();
|
|
|
|
this.onTypeahead = debounce(this.onTypeahead, TYPEAHEAD_DELAY);
|
|
}
|
|
|
|
componentDidMount() {
|
|
super.componentDidMount();
|
|
this.fetchSchema();
|
|
}
|
|
|
|
onTypeahead = (force = false) => {
|
|
const selection = window.getSelection();
|
|
|
|
if (selection && selection.anchorNode) {
|
|
const wrapperNode = selection.anchorNode.parentElement;
|
|
if (wrapperNode === null) {
|
|
return;
|
|
}
|
|
const editorNode = wrapperNode.closest('.slate-query-field');
|
|
if (!editorNode || this.state.value.isBlurred) {
|
|
// Not inside this editor
|
|
return;
|
|
}
|
|
|
|
// DOM ranges
|
|
const range = selection.getRangeAt(0);
|
|
const text = selection.anchorNode.textContent;
|
|
if (text === null) {
|
|
return;
|
|
}
|
|
const offset = range.startOffset;
|
|
let prefix = cleanText(text.substr(0, offset));
|
|
|
|
// Model ranges
|
|
const modelOffset = this.state.value.anchorOffset;
|
|
const modelPrefix = this.state.value.anchorText.text.slice(0, modelOffset);
|
|
|
|
// Determine candidates by context
|
|
let suggestionGroups: SuggestionGroup[] = [];
|
|
const wrapperClasses = wrapperNode.classList;
|
|
let typeaheadContext: string | null = null;
|
|
|
|
// Built-in functions
|
|
if (wrapperClasses.contains('function-context')) {
|
|
typeaheadContext = 'context-function';
|
|
suggestionGroups = this.getColumnSuggestions();
|
|
|
|
// where
|
|
} else if (modelPrefix.match(/(where\s(\w+\b)?$)/i)) {
|
|
typeaheadContext = 'context-where';
|
|
suggestionGroups = this.getColumnSuggestions();
|
|
|
|
// summarize by
|
|
} else if (modelPrefix.match(/(summarize\s(\w+\b)?$)/i)) {
|
|
typeaheadContext = 'context-summarize';
|
|
suggestionGroups = this.getFunctionSuggestions();
|
|
} else if (modelPrefix.match(/(summarize\s(.+\s)?by\s+([^,\s]+,\s*)*([^,\s]+\b)?$)/i)) {
|
|
typeaheadContext = 'context-summarize-by';
|
|
suggestionGroups = this.getColumnSuggestions();
|
|
|
|
// order by, top X by, ... by ...
|
|
} else if (modelPrefix.match(/(by\s+([^,\s]+,\s*)*([^,\s]+\b)?$)/i)) {
|
|
typeaheadContext = 'context-by';
|
|
suggestionGroups = this.getColumnSuggestions();
|
|
|
|
// join
|
|
} else if (modelPrefix.match(/(on\s(.+\b)?$)/i)) {
|
|
typeaheadContext = 'context-join-on';
|
|
suggestionGroups = this.getColumnSuggestions();
|
|
} else if (modelPrefix.match(/(join\s+(\(\s+)?(\w+\b)?$)/i)) {
|
|
typeaheadContext = 'context-join';
|
|
suggestionGroups = this.getTableSuggestions();
|
|
|
|
// distinct
|
|
} else if (modelPrefix.match(/(distinct\s(.+\b)?$)/i)) {
|
|
typeaheadContext = 'context-distinct';
|
|
suggestionGroups = this.getColumnSuggestions();
|
|
|
|
// database()
|
|
} else if (modelPrefix.match(/(database\(\"(\w+)\"\)\.(.+\b)?$)/i)) {
|
|
typeaheadContext = 'context-database-table';
|
|
const db = this.getDBFromDatabaseFunction(modelPrefix);
|
|
suggestionGroups = this.getTableSuggestions(db);
|
|
prefix = prefix.replace('.', '');
|
|
|
|
// new
|
|
} else if (normalizeQuery(Plain.serialize(this.state.value)).match(/^\s*\w*$/i)) {
|
|
typeaheadContext = 'context-new';
|
|
if (this.schema) {
|
|
suggestionGroups = this.getInitialSuggestions();
|
|
} else {
|
|
this.fetchSchema();
|
|
setTimeout(this.onTypeahead, 0);
|
|
return;
|
|
}
|
|
|
|
// built-in
|
|
} else if (prefix && !wrapperClasses.contains('argument') && !force) {
|
|
// Use only last typed word as a prefix for searching
|
|
if (modelPrefix.match(/\s$/i)) {
|
|
prefix = '';
|
|
return;
|
|
}
|
|
prefix = getLastWord(prefix);
|
|
typeaheadContext = 'context-builtin';
|
|
suggestionGroups = this.getKeywordSuggestions();
|
|
} else if (force === true) {
|
|
typeaheadContext = 'context-builtin-forced';
|
|
if (modelPrefix.match(/\s$/i)) {
|
|
prefix = '';
|
|
}
|
|
suggestionGroups = this.getKeywordSuggestions();
|
|
}
|
|
|
|
let results = 0;
|
|
prefix = prefix.toLowerCase();
|
|
const filteredSuggestions = suggestionGroups
|
|
.map((group) => {
|
|
if (group.items && prefix && !group.skipFilter) {
|
|
group.items = group.items.filter((c) => c.text.length >= prefix.length);
|
|
if (group.prefixMatch) {
|
|
group.items = group.items.filter((c) => c.text.toLowerCase().indexOf(prefix) === 0);
|
|
} else {
|
|
group.items = group.items.filter((c) => c.text.toLowerCase().indexOf(prefix) > -1);
|
|
}
|
|
}
|
|
results += group.items.length;
|
|
return group;
|
|
})
|
|
.filter((group) => group.items.length > 0);
|
|
|
|
// console.log('onTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
|
|
// console.log('onTypeahead', prefix, typeaheadContext, force);
|
|
|
|
this.setState({
|
|
typeaheadPrefix: prefix,
|
|
typeaheadContext,
|
|
typeaheadText: text,
|
|
suggestions: results > 0 ? filteredSuggestions : [],
|
|
});
|
|
}
|
|
};
|
|
|
|
applyTypeahead = (editor: CoreEditor, suggestion: { text: any; type: string; deleteBackwards: any }): CoreEditor => {
|
|
const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
|
|
let suggestionText = suggestion.text || suggestion;
|
|
const move = 0;
|
|
|
|
// Modify suggestion based on context
|
|
|
|
const nextChar = DOMUtil.getNextCharacter();
|
|
if (suggestion.type === 'function') {
|
|
if (!nextChar || nextChar !== '(') {
|
|
suggestionText += '(';
|
|
}
|
|
} else if (typeaheadContext === 'context-function') {
|
|
if (!nextChar || nextChar !== ')') {
|
|
suggestionText += ')';
|
|
}
|
|
} else {
|
|
if (!nextChar || nextChar !== ' ') {
|
|
suggestionText += ' ';
|
|
}
|
|
}
|
|
|
|
// Remove the current, incomplete text and replace it with the selected suggestion
|
|
const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
|
|
const text = cleanText(typeaheadText);
|
|
const suffixLength = text.length - typeaheadPrefix.length;
|
|
const offset = typeaheadText.indexOf(typeaheadPrefix);
|
|
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
|
|
const forward = midWord ? suffixLength + offset : 0;
|
|
|
|
this.resetTypeahead(() =>
|
|
editor.deleteBackward(backward).deleteForward(forward).insertText(suggestionText).moveForward(move).focus()
|
|
);
|
|
|
|
return editor;
|
|
};
|
|
|
|
// private _getFieldsSuggestions(): SuggestionGroup[] {
|
|
// return [
|
|
// {
|
|
// prefixMatch: true,
|
|
// label: 'Fields',
|
|
// items: this.fields.map(wrapText)
|
|
// },
|
|
// {
|
|
// prefixMatch: true,
|
|
// label: 'Variables',
|
|
// items: this.props.templateVariables.map(wrapText)
|
|
// }
|
|
// ];
|
|
// }
|
|
|
|
// private _getAfterFromSuggestions(): SuggestionGroup[] {
|
|
// return [
|
|
// {
|
|
// skipFilter: true,
|
|
// label: 'Events',
|
|
// items: this.events.map(wrapText)
|
|
// },
|
|
// {
|
|
// prefixMatch: true,
|
|
// label: 'Variables',
|
|
// items: this.props.templateVariables
|
|
// .map(wrapText)
|
|
// .map(suggestion => {
|
|
// suggestion.deleteBackwards = 0;
|
|
// return suggestion;
|
|
// })
|
|
// }
|
|
// ];
|
|
// }
|
|
|
|
// private _getAfterSelectSuggestions(): SuggestionGroup[] {
|
|
// return [
|
|
// {
|
|
// prefixMatch: true,
|
|
// label: 'Fields',
|
|
// items: this.fields.map(wrapText)
|
|
// },
|
|
// {
|
|
// prefixMatch: true,
|
|
// label: 'Functions',
|
|
// items: FUNCTIONS.map((s: any) => { s.type = 'function'; return s; })
|
|
// },
|
|
// {
|
|
// prefixMatch: true,
|
|
// label: 'Variables',
|
|
// items: this.props.templateVariables.map(wrapText)
|
|
// }
|
|
// ];
|
|
// }
|
|
|
|
private getInitialSuggestions(): SuggestionGroup[] {
|
|
return this.getTableSuggestions();
|
|
}
|
|
|
|
private getKeywordSuggestions(): SuggestionGroup[] {
|
|
return [
|
|
{
|
|
prefixMatch: true,
|
|
label: 'Keywords',
|
|
items: KEYWORDS.map(wrapText),
|
|
},
|
|
{
|
|
prefixMatch: true,
|
|
label: 'Operators',
|
|
items: operatorTokens,
|
|
},
|
|
{
|
|
prefixMatch: true,
|
|
label: 'Functions',
|
|
items: functionTokens.map((s: any) => {
|
|
s.type = 'function';
|
|
return s;
|
|
}),
|
|
},
|
|
{
|
|
prefixMatch: true,
|
|
label: 'Macros',
|
|
items: grafanaMacros.map((s: any) => {
|
|
s.type = 'function';
|
|
return s;
|
|
}),
|
|
},
|
|
{
|
|
prefixMatch: true,
|
|
label: 'Tables',
|
|
items: map(this.schema.Databases.Default.Tables, (t: any) => ({ text: t.Name })),
|
|
},
|
|
];
|
|
}
|
|
|
|
private getFunctionSuggestions(): SuggestionGroup[] {
|
|
return [
|
|
{
|
|
prefixMatch: true,
|
|
label: 'Functions',
|
|
items: functionTokens.map((s: any) => {
|
|
s.type = 'function';
|
|
return s;
|
|
}),
|
|
},
|
|
{
|
|
prefixMatch: true,
|
|
label: 'Macros',
|
|
items: grafanaMacros.map((s: any) => {
|
|
s.type = 'function';
|
|
return s;
|
|
}),
|
|
},
|
|
];
|
|
}
|
|
|
|
getTableSuggestions(db = 'Default'): SuggestionGroup[] {
|
|
// @ts-ignore
|
|
if (this.schema.Databases[db]) {
|
|
return [
|
|
{
|
|
prefixMatch: true,
|
|
label: 'Tables',
|
|
// @ts-ignore
|
|
items: map(this.schema.Databases[db].Tables, (t: any) => ({ text: t.Name })),
|
|
},
|
|
];
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private getColumnSuggestions(): SuggestionGroup[] {
|
|
const table = this.getTableFromContext();
|
|
if (table) {
|
|
const tableSchema = this.schema.Databases.Default.Tables[table];
|
|
if (tableSchema) {
|
|
return [
|
|
{
|
|
prefixMatch: true,
|
|
label: 'Fields',
|
|
items: map(tableSchema.OrderedColumns, (f: any) => ({
|
|
text: f.Name,
|
|
hint: f.Type,
|
|
})),
|
|
},
|
|
];
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
private getTableFromContext() {
|
|
const query = Plain.serialize(this.state.value);
|
|
const tablePattern = /^\s*(\w+)\s*|/g;
|
|
const normalizedQuery = normalizeQuery(query);
|
|
const match = tablePattern.exec(normalizedQuery);
|
|
if (match && match.length > 1 && match[0] && match[1]) {
|
|
return match[1];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private getDBFromDatabaseFunction(prefix: string) {
|
|
const databasePattern = /database\(\"(\w+)\"\)/gi;
|
|
const match = databasePattern.exec(prefix);
|
|
if (match && match.length > 1 && match[0] && match[1]) {
|
|
return match[1];
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private async fetchSchema() {
|
|
let schema = await this.props.getSchema();
|
|
if (schema) {
|
|
if (schema.Type === 'AppInsights') {
|
|
schema = castSchema(schema);
|
|
}
|
|
this.schema = schema;
|
|
} else {
|
|
this.schema = defaultSchema();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cast schema from App Insights to default Kusto schema
|
|
*/
|
|
function castSchema(schema: any) {
|
|
const defaultSchemaTemplate = defaultSchema();
|
|
defaultSchemaTemplate.Databases.Default = schema;
|
|
return defaultSchemaTemplate;
|
|
}
|
|
|
|
function normalizeQuery(query: string): string {
|
|
const commentPattern = /\/\/.*$/gm;
|
|
let normalizedQuery = query.replace(commentPattern, '');
|
|
normalizedQuery = normalizedQuery.replace('\n', ' ');
|
|
return normalizedQuery;
|
|
}
|
|
|
|
function getLastWord(str: string): string {
|
|
const lastWordPattern = /(?:.*\s)?([^\s]+\s*)$/gi;
|
|
const match = lastWordPattern.exec(str);
|
|
if (match && match.length > 1) {
|
|
return match[1];
|
|
}
|
|
return '';
|
|
}
|