mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Prometheus: Use fuzzy string matching to autocomplete metric names and label (#32207)
* Fuzzy search prototype * Aggregate filter and sorting functions for auto-complete suggestions * Add a test for fuzzy search * Simplify setting fuzzy search information * Rename SimpleHighlighter * Test PartialHighlighter * Add PartialHighlighter snapshot * Simplify PartialHighlighter * Revert env change * Clean up the code * Add fuzzy search for labels * Bring back backwards compatiblity * Expose search function type only * Update docs * Covert snapshot test to assertions * Fix docs * Fix language provider test * Add a test for autocomplete logic * Clean up * Mock Editor functions * Add fuzzy search to Prometheus labels * Add docs about backwards compatibility * Simplify main fuzzy search loop
This commit is contained in:
parent
2c862678ab
commit
dd095642e2
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { PartialHighlighter } from './PartialHighlighter';
|
||||
|
||||
function assertPart(component: ReactWrapper, isHighlighted: boolean, text: string): void {
|
||||
expect(component.type()).toEqual(isHighlighted ? 'mark' : 'span');
|
||||
expect(component.hasClass('highlight')).toEqual(isHighlighted);
|
||||
expect(component.text()).toEqual(text);
|
||||
}
|
||||
|
||||
describe('PartialHighlighter component', () => {
|
||||
it('should highlight inner parts correctly', () => {
|
||||
const component = mount(
|
||||
<PartialHighlighter
|
||||
text="Lorem ipsum dolor sit amet"
|
||||
highlightClassName="highlight"
|
||||
highlightParts={[
|
||||
{ start: 6, end: 10 },
|
||||
{ start: 18, end: 20 },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const main = component.find('div');
|
||||
|
||||
assertPart(main.childAt(0), false, 'Lorem ');
|
||||
assertPart(main.childAt(1), true, 'ipsum');
|
||||
assertPart(main.childAt(2), false, ' dolor ');
|
||||
assertPart(main.childAt(3), true, 'sit');
|
||||
assertPart(main.childAt(4), false, ' amet');
|
||||
});
|
||||
|
||||
it('should highlight outer parts correctly', () => {
|
||||
const component = mount(
|
||||
<PartialHighlighter
|
||||
text="Lorem ipsum dolor sit amet"
|
||||
highlightClassName="highlight"
|
||||
highlightParts={[
|
||||
{ start: 0, end: 4 },
|
||||
{ start: 22, end: 25 },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const main = component.find('div');
|
||||
assertPart(main.childAt(0), true, 'Lorem');
|
||||
assertPart(main.childAt(1), false, ' ipsum dolor sit ');
|
||||
assertPart(main.childAt(2), true, 'amet');
|
||||
});
|
||||
});
|
@ -0,0 +1,55 @@
|
||||
import React, { createElement } from 'react';
|
||||
import { HighlightPart } from '../../types';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
highlightParts: HighlightPart[];
|
||||
highlightClassName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens parts into a list of indices pointing to the index where a part
|
||||
* (highlighted or not highlighted) starts. Adds extra indices if needed
|
||||
* at the beginning or the end to ensure the entire text is covered.
|
||||
*/
|
||||
function getStartIndices(parts: HighlightPart[], length: number): number[] {
|
||||
const indices: number[] = [];
|
||||
parts.forEach((part) => {
|
||||
indices.push(part.start, part.end + 1);
|
||||
});
|
||||
if (indices[0] !== 0) {
|
||||
indices.unshift(0);
|
||||
}
|
||||
if (indices[indices.length - 1] !== length) {
|
||||
indices.push(length);
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
export const PartialHighlighter: React.FC<Props> = (props: Props) => {
|
||||
let { highlightParts, text, highlightClassName } = props;
|
||||
|
||||
if (!highlightParts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let children = [];
|
||||
let indices = getStartIndices(highlightParts, text.length);
|
||||
let highlighted = highlightParts[0].start === 0;
|
||||
|
||||
for (let i = 1; i < indices.length; i++) {
|
||||
let start = indices[i - 1];
|
||||
let end = indices[i];
|
||||
|
||||
children.push(
|
||||
createElement(highlighted ? 'mark' : 'span', {
|
||||
key: i - 1,
|
||||
children: text.substring(start, end),
|
||||
className: highlighted ? highlightClassName : undefined,
|
||||
})
|
||||
);
|
||||
highlighted = !highlighted;
|
||||
}
|
||||
|
||||
return <div>{children}</div>;
|
||||
};
|
@ -6,6 +6,7 @@ import { css, cx } from '@emotion/css';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { CompletionItem, CompletionItemKind } from '../../types/completion';
|
||||
import { ThemeContext } from '../../themes/ThemeContext';
|
||||
import { PartialHighlighter } from './PartialHighlighter';
|
||||
|
||||
interface Props {
|
||||
isSelected: boolean;
|
||||
@ -83,7 +84,15 @@ export const TypeaheadItem: React.FC<Props> = (props: Props) => {
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName={highlightClassName} />
|
||||
{item.highlightParts !== undefined ? (
|
||||
<PartialHighlighter
|
||||
text={label}
|
||||
highlightClassName={highlightClassName}
|
||||
highlightParts={item.highlightParts}
|
||||
></PartialHighlighter>
|
||||
) : (
|
||||
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName={highlightClassName} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
79
packages/grafana-ui/src/slate-plugins/fuzzy.test.ts
Normal file
79
packages/grafana-ui/src/slate-plugins/fuzzy.test.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { fuzzyMatch } from './fuzzy';
|
||||
|
||||
describe('Fuzzy search', () => {
|
||||
it('finds only matching elements', () => {
|
||||
expect(fuzzyMatch('foo', 'foo')).toEqual({
|
||||
distance: 0,
|
||||
ranges: [{ start: 0, end: 2 }],
|
||||
found: true,
|
||||
});
|
||||
|
||||
expect(fuzzyMatch('foo_bar', 'foo')).toEqual({
|
||||
distance: 0,
|
||||
ranges: [{ start: 0, end: 2 }],
|
||||
found: true,
|
||||
});
|
||||
|
||||
expect(fuzzyMatch('bar', 'foo')).toEqual({
|
||||
distance: Infinity,
|
||||
ranges: [],
|
||||
found: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('is case sensitive', () => {
|
||||
expect(fuzzyMatch('foo_bar', 'BAR')).toEqual({
|
||||
distance: Infinity,
|
||||
ranges: [],
|
||||
found: false,
|
||||
});
|
||||
expect(fuzzyMatch('Foo_Bar', 'bar')).toEqual({
|
||||
distance: Infinity,
|
||||
ranges: [],
|
||||
found: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('finds highlight ranges with single letters', () => {
|
||||
expect(fuzzyMatch('foo_xyzzy_bar', 'fxb')).toEqual({
|
||||
ranges: [
|
||||
{ start: 0, end: 0 },
|
||||
{ start: 4, end: 4 },
|
||||
{ start: 10, end: 10 },
|
||||
],
|
||||
distance: 8,
|
||||
found: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('finds highlight ranges for multiple outer words', () => {
|
||||
expect(fuzzyMatch('foo_xyzzy_bar', 'foobar')).toEqual({
|
||||
ranges: [
|
||||
{ start: 0, end: 2 },
|
||||
{ start: 10, end: 12 },
|
||||
],
|
||||
distance: 7,
|
||||
found: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('finds highlight ranges for multiple inner words', () => {
|
||||
expect(fuzzyMatch('foo_xyzzy_bar', 'oozzyba')).toEqual({
|
||||
ranges: [
|
||||
{ start: 1, end: 2 },
|
||||
{ start: 6, end: 8 },
|
||||
{ start: 10, end: 11 },
|
||||
],
|
||||
distance: 4,
|
||||
found: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('promotes exact matches', () => {
|
||||
expect(fuzzyMatch('bbaarr_bar_bbaarr', 'bar')).toEqual({
|
||||
ranges: [{ start: 7, end: 9 }],
|
||||
distance: 0,
|
||||
found: true,
|
||||
});
|
||||
});
|
||||
});
|
67
packages/grafana-ui/src/slate-plugins/fuzzy.ts
Normal file
67
packages/grafana-ui/src/slate-plugins/fuzzy.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { HighlightPart } from '../types';
|
||||
import { last } from 'lodash';
|
||||
|
||||
type FuzzyMatch = {
|
||||
/**
|
||||
* Total number of unmatched letters between matched letters
|
||||
*/
|
||||
distance: number;
|
||||
ranges: HighlightPart[];
|
||||
found: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempts to do a partial input search, e.g. allowing to search for a text (needle)
|
||||
* in another text (stack) by skipping some letters in-between. All letters from
|
||||
* the needle must exist in the stack in the same order to find a match.
|
||||
*
|
||||
* The search is case sensitive. Convert stack and needle to lower case
|
||||
* to make it case insensitive.
|
||||
*
|
||||
* @param stack - main text to be searched
|
||||
* @param needle - partial text to find in the stack
|
||||
*/
|
||||
export function fuzzyMatch(stack: string, needle: string): FuzzyMatch {
|
||||
let distance = 0,
|
||||
searchIndex = stack.indexOf(needle);
|
||||
|
||||
const ranges: HighlightPart[] = [];
|
||||
|
||||
if (searchIndex !== -1) {
|
||||
return {
|
||||
distance: 0,
|
||||
found: true,
|
||||
ranges: [{ start: searchIndex, end: searchIndex + needle.length - 1 }],
|
||||
};
|
||||
}
|
||||
|
||||
for (const letter of needle) {
|
||||
const letterIndex = stack.indexOf(letter, searchIndex);
|
||||
|
||||
if (letterIndex === -1) {
|
||||
return { distance: Infinity, ranges: [], found: false };
|
||||
}
|
||||
// do not cumulate the distance if it's the first letter
|
||||
if (searchIndex !== -1) {
|
||||
distance += letterIndex - searchIndex;
|
||||
}
|
||||
searchIndex = letterIndex + 1;
|
||||
|
||||
if (ranges.length === 0) {
|
||||
ranges.push({ start: letterIndex, end: letterIndex });
|
||||
} else {
|
||||
const lastRange = last(ranges)!;
|
||||
if (letterIndex === lastRange.end + 1) {
|
||||
lastRange.end++;
|
||||
} else {
|
||||
ranges.push({ start: letterIndex, end: letterIndex });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
distance: distance,
|
||||
ranges,
|
||||
found: true,
|
||||
};
|
||||
}
|
156
packages/grafana-ui/src/slate-plugins/suggestions.test.tsx
Normal file
156
packages/grafana-ui/src/slate-plugins/suggestions.test.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import { SearchFunctionMap } from '../utils/searchFunctions';
|
||||
import { render } from 'enzyme';
|
||||
import { SuggestionsPlugin } from './suggestions';
|
||||
import { Plugin as SlatePlugin } from '@grafana/slate-react';
|
||||
import { SearchFunctionType } from '../utils';
|
||||
import { CompletionItemGroup, SuggestionsState } from '../types';
|
||||
|
||||
jest.mock('../utils/searchFunctions', () => ({
|
||||
// @ts-ignore
|
||||
...jest.requireActual('../utils/searchFunctions'),
|
||||
SearchFunctionMap: {
|
||||
Prefix: jest.fn((items) => items),
|
||||
Word: jest.fn((items) => items),
|
||||
Fuzzy: jest.fn((items) => items),
|
||||
},
|
||||
}));
|
||||
|
||||
const TypeaheadMock = jest.fn(() => '');
|
||||
jest.mock('../components/Typeahead/Typeahead', () => {
|
||||
return {
|
||||
Typeahead: (state: Partial<SuggestionsState>) => {
|
||||
// @ts-ignore
|
||||
TypeaheadMock(state);
|
||||
return '';
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('lodash/debounce', () => {
|
||||
return (func: () => any) => func;
|
||||
});
|
||||
|
||||
describe('SuggestionsPlugin', () => {
|
||||
let plugin: SlatePlugin, nextMock: any, suggestions: CompletionItemGroup[], editorMock: any, eventMock: any;
|
||||
|
||||
beforeEach(() => {
|
||||
let onTypeahead = async () => {
|
||||
return {
|
||||
suggestions: suggestions,
|
||||
};
|
||||
};
|
||||
|
||||
(SearchFunctionMap.Prefix as jest.Mock).mockClear();
|
||||
(SearchFunctionMap.Word as jest.Mock).mockClear();
|
||||
(SearchFunctionMap.Fuzzy as jest.Mock).mockClear();
|
||||
|
||||
plugin = SuggestionsPlugin({ portalOrigin: '', onTypeahead });
|
||||
nextMock = () => {};
|
||||
editorMock = createEditorMock('foo');
|
||||
eventMock = new window.KeyboardEvent('keydown', { key: 'a' });
|
||||
});
|
||||
|
||||
async function triggerAutocomplete() {
|
||||
await plugin.onKeyDown!(eventMock, editorMock, nextMock);
|
||||
render(plugin.renderEditor!({} as any, editorMock, nextMock));
|
||||
}
|
||||
|
||||
it('is backward compatible with prefixMatch and sortText', async () => {
|
||||
suggestions = [
|
||||
{
|
||||
label: 'group',
|
||||
prefixMatch: true,
|
||||
items: [
|
||||
{ label: 'foobar', sortText: '3' },
|
||||
{ label: 'foobar', sortText: '1' },
|
||||
{ label: 'foobar', sortText: '2' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await triggerAutocomplete();
|
||||
|
||||
expect(SearchFunctionMap.Word).not.toBeCalled();
|
||||
expect(SearchFunctionMap.Fuzzy).not.toBeCalled();
|
||||
expect(SearchFunctionMap.Prefix).toBeCalled();
|
||||
|
||||
expect(TypeaheadMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
groupedItems: [
|
||||
{
|
||||
label: 'group',
|
||||
prefixMatch: true,
|
||||
items: [
|
||||
{ label: 'foobar', sortText: '1' },
|
||||
{ label: 'foobar', sortText: '2' },
|
||||
{ label: 'foobar', sortText: '3' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses searchFunction to create autocomplete list and sortValue if defined', async () => {
|
||||
suggestions = [
|
||||
{
|
||||
label: 'group',
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
items: [
|
||||
{ label: 'foobar', sortValue: 3 },
|
||||
{ label: 'foobar', sortValue: 1 },
|
||||
{ label: 'foobar', sortValue: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await triggerAutocomplete();
|
||||
|
||||
expect(SearchFunctionMap.Word).not.toBeCalled();
|
||||
expect(SearchFunctionMap.Prefix).not.toBeCalled();
|
||||
expect(SearchFunctionMap.Fuzzy).toBeCalled();
|
||||
|
||||
expect(TypeaheadMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
groupedItems: [
|
||||
{
|
||||
label: 'group',
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
items: [
|
||||
{ label: 'foobar', sortValue: 1 },
|
||||
{ label: 'foobar', sortValue: 2 },
|
||||
{ label: 'foobar', sortValue: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createEditorMock(currentText: string) {
|
||||
return {
|
||||
blur: jest.fn().mockReturnThis(),
|
||||
focus: jest.fn().mockReturnThis(),
|
||||
value: {
|
||||
selection: {
|
||||
start: {
|
||||
offset: 0,
|
||||
},
|
||||
end: {
|
||||
offset: 0,
|
||||
},
|
||||
focus: {
|
||||
offset: currentText.length,
|
||||
},
|
||||
},
|
||||
document: {
|
||||
getClosestBlock: () => {},
|
||||
},
|
||||
focusText: {
|
||||
text: currentText,
|
||||
},
|
||||
focusBlock: {},
|
||||
},
|
||||
};
|
||||
}
|
@ -7,8 +7,9 @@ import { Plugin as SlatePlugin } from '@grafana/slate-react';
|
||||
|
||||
import TOKEN_MARK from './slate-prism/TOKEN_MARK';
|
||||
import { Typeahead } from '../components/Typeahead/Typeahead';
|
||||
import { CompletionItem, TypeaheadOutput, TypeaheadInput, SuggestionsState } from '../types/completion';
|
||||
import { makeFragment } from '../utils/slate';
|
||||
import { CompletionItem, SuggestionsState, TypeaheadInput, TypeaheadOutput } from '../types';
|
||||
import { makeFragment, SearchFunctionType } from '../utils';
|
||||
import { SearchFunctionMap } from '../utils/searchFunctions';
|
||||
|
||||
export const TYPEAHEAD_DEBOUNCE = 250;
|
||||
|
||||
@ -289,17 +290,16 @@ const handleTypeahead = async (
|
||||
if (!group.items) {
|
||||
return group;
|
||||
}
|
||||
|
||||
// Falling back to deprecated prefixMatch to support backwards compatibility with plugins using this property
|
||||
const searchFunctionType =
|
||||
group.searchFunctionType || (group.prefixMatch ? SearchFunctionType.Prefix : SearchFunctionType.Word);
|
||||
const searchFunction = SearchFunctionMap[searchFunctionType];
|
||||
let newGroup = { ...group };
|
||||
if (prefix) {
|
||||
// Filter groups based on prefix
|
||||
if (!group.skipFilter) {
|
||||
newGroup.items = newGroup.items.filter((c) => (c.filterText || c.label).length >= prefix.length);
|
||||
if (group.prefixMatch) {
|
||||
newGroup.items = newGroup.items.filter((c) => (c.filterText || c.label).startsWith(prefix));
|
||||
} else {
|
||||
newGroup.items = newGroup.items.filter((c) => (c.filterText || c.label).includes(prefix));
|
||||
}
|
||||
newGroup.items = searchFunction(newGroup.items, prefix);
|
||||
}
|
||||
|
||||
// Filter out the already typed value (prefix) unless it inserts custom text not matching the prefix
|
||||
@ -309,7 +309,14 @@ const handleTypeahead = async (
|
||||
}
|
||||
|
||||
if (!group.skipSort) {
|
||||
newGroup.items = sortBy(newGroup.items, (item: CompletionItem) => item.sortText || item.label);
|
||||
newGroup.items = sortBy(newGroup.items, (item: CompletionItem) => {
|
||||
if (item.sortText === undefined) {
|
||||
return item.sortValue !== undefined ? item.sortValue : item.label;
|
||||
} else {
|
||||
// Falling back to deprecated sortText to support backwards compatibility with plugins using this property
|
||||
return item.sortText || item.label;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newGroup;
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { Value, Editor as CoreEditor } from 'slate';
|
||||
import { SearchFunctionType } from '../utils';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type SearchFunction = (items: CompletionItem[], prefix: string) => CompletionItem[];
|
||||
|
||||
export interface CompletionItemGroup {
|
||||
/**
|
||||
@ -13,9 +19,16 @@ export interface CompletionItemGroup {
|
||||
|
||||
/**
|
||||
* If true, match only by prefix (and not mid-word).
|
||||
* @deprecated use searchFunctionType instead
|
||||
*/
|
||||
prefixMatch?: boolean;
|
||||
|
||||
/**
|
||||
* Function type used to create auto-complete list
|
||||
* @alpha
|
||||
*/
|
||||
searchFunctionType?: SearchFunctionType;
|
||||
|
||||
/**
|
||||
* If true, do not filter items in this group based on the search.
|
||||
*/
|
||||
@ -31,6 +44,14 @@ export enum CompletionItemKind {
|
||||
GroupTitle = 'GroupTitle',
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type HighlightPart = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
export interface CompletionItem {
|
||||
/**
|
||||
* The label of this completion item. By default
|
||||
@ -59,9 +80,23 @@ export interface CompletionItem {
|
||||
/**
|
||||
* A string that should be used when comparing this item
|
||||
* with other items. When `falsy` the `label` is used.
|
||||
* @deprecated use sortValue instead
|
||||
*/
|
||||
sortText?: string;
|
||||
|
||||
/**
|
||||
* A string or number that should be used when comparing this
|
||||
* item with other items. When `undefined` then `label` is used.
|
||||
* @alpha
|
||||
*/
|
||||
sortValue?: string | number;
|
||||
|
||||
/**
|
||||
* Parts of the label to be highlighted
|
||||
* @internal
|
||||
*/
|
||||
highlightParts?: HighlightPart[];
|
||||
|
||||
/**
|
||||
* A string that should be used when filtering a set of
|
||||
* completion items. When `falsy` the `label` is used.
|
||||
|
@ -6,6 +6,7 @@ export * from './tags';
|
||||
export * from './scrollbar';
|
||||
export * from './measureText';
|
||||
export * from './useForceUpdate';
|
||||
export { SearchFunctionType } from './searchFunctions';
|
||||
export { default as ansicolor } from './ansicolor';
|
||||
|
||||
import * as DOMUtil from './dom'; // includes Element.closest polyfill
|
||||
|
58
packages/grafana-ui/src/utils/searchFunctions.ts
Normal file
58
packages/grafana-ui/src/utils/searchFunctions.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { CompletionItem, SearchFunction } from '../types';
|
||||
import { fuzzyMatch } from '../slate-plugins/fuzzy';
|
||||
|
||||
/**
|
||||
* List of auto-complete search function used by SuggestionsPlugin.handleTypeahead()
|
||||
* @alpha
|
||||
*/
|
||||
export enum SearchFunctionType {
|
||||
Word = 'Word',
|
||||
Prefix = 'Prefix',
|
||||
Fuzzy = 'Fuzzy',
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact-word matching for auto-complete suggestions.
|
||||
* - Returns items containing the searched text.
|
||||
* @internal
|
||||
*/
|
||||
const wordSearch: SearchFunction = (items: CompletionItem[], text: string): CompletionItem[] => {
|
||||
return items.filter((c) => (c.filterText || c.label).includes(text));
|
||||
};
|
||||
|
||||
/**
|
||||
* Prefix-based search for auto-complete suggestions.
|
||||
* - Returns items starting with the searched text.
|
||||
* @internal
|
||||
*/
|
||||
const prefixSearch: SearchFunction = (items: CompletionItem[], text: string): CompletionItem[] => {
|
||||
return items.filter((c) => (c.filterText || c.label).startsWith(text));
|
||||
};
|
||||
|
||||
/**
|
||||
* Fuzzy search for auto-complete suggestions.
|
||||
* - Returns items containing all letters from the search text occurring in the same order.
|
||||
* - Stores highlight parts with parts of the text phrase found by fuzzy search
|
||||
* @internal
|
||||
*/
|
||||
const fuzzySearch: SearchFunction = (items: CompletionItem[], text: string): CompletionItem[] => {
|
||||
text = text.toLowerCase();
|
||||
return items.filter((item) => {
|
||||
const { distance, ranges, found } = fuzzyMatch(item.label.toLowerCase(), text);
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
item.sortValue = distance;
|
||||
item.highlightParts = ranges;
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export const SearchFunctionMap = {
|
||||
[SearchFunctionType.Word]: wordSearch,
|
||||
[SearchFunctionType.Prefix]: prefixSearch,
|
||||
[SearchFunctionType.Fuzzy]: fuzzySearch,
|
||||
};
|
@ -18,7 +18,7 @@ import { CloudWatchQuery, TSDBResponse } from './types';
|
||||
import { AbsoluteTimeRange, HistoryItem, LanguageProvider } from '@grafana/data';
|
||||
|
||||
import { CloudWatchDatasource } from './datasource';
|
||||
import { Token, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
|
||||
import { CompletionItemGroup, SearchFunctionType, Token, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
|
||||
import Prism, { Grammar } from 'prismjs';
|
||||
|
||||
export type CloudWatchHistoryItem = HistoryItem<CloudWatchQuery>;
|
||||
@ -167,8 +167,12 @@ export class CloudWatchLanguageProvider extends LanguageProvider {
|
||||
|
||||
private handleKeyword = async (context?: TypeaheadContext): Promise<TypeaheadOutput> => {
|
||||
const suggs = await this.getFieldCompletionItems(context?.logGroupNames ?? []);
|
||||
const functionSuggestions = [
|
||||
{ prefixMatch: true, label: 'Functions', items: STRING_FUNCTIONS.concat(DATETIME_FUNCTIONS, IP_FUNCTIONS) },
|
||||
const functionSuggestions: CompletionItemGroup[] = [
|
||||
{
|
||||
searchFunctionType: SearchFunctionType.Prefix,
|
||||
label: 'Functions',
|
||||
items: STRING_FUNCTIONS.concat(DATETIME_FUNCTIONS, IP_FUNCTIONS),
|
||||
},
|
||||
];
|
||||
suggs.suggestions.push(...functionSuggestions);
|
||||
|
||||
@ -244,7 +248,7 @@ export class CloudWatchLanguageProvider extends LanguageProvider {
|
||||
return {
|
||||
suggestions: [
|
||||
{
|
||||
prefixMatch: true,
|
||||
searchFunctionType: SearchFunctionType.Prefix,
|
||||
label: 'Sort Order',
|
||||
items: [
|
||||
{
|
||||
@ -268,22 +272,32 @@ export class CloudWatchLanguageProvider extends LanguageProvider {
|
||||
};
|
||||
|
||||
private getCommandCompletionItems = (): TypeaheadOutput => {
|
||||
return { suggestions: [{ prefixMatch: true, label: 'Commands', items: QUERY_COMMANDS }] };
|
||||
return {
|
||||
suggestions: [{ searchFunctionType: SearchFunctionType.Prefix, label: 'Commands', items: QUERY_COMMANDS }],
|
||||
};
|
||||
};
|
||||
|
||||
private getFieldAndFilterFunctionCompletionItems = (): TypeaheadOutput => {
|
||||
return { suggestions: [{ prefixMatch: true, label: 'Functions', items: FIELD_AND_FILTER_FUNCTIONS }] };
|
||||
return {
|
||||
suggestions: [
|
||||
{ searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: FIELD_AND_FILTER_FUNCTIONS },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
private getStatsAggCompletionItems = (): TypeaheadOutput => {
|
||||
return { suggestions: [{ prefixMatch: true, label: 'Functions', items: AGGREGATION_FUNCTIONS_STATS }] };
|
||||
return {
|
||||
suggestions: [
|
||||
{ searchFunctionType: SearchFunctionType.Prefix, label: 'Functions', items: AGGREGATION_FUNCTIONS_STATS },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
private getBoolFuncCompletionItems = (): TypeaheadOutput => {
|
||||
return {
|
||||
suggestions: [
|
||||
{
|
||||
prefixMatch: true,
|
||||
searchFunctionType: SearchFunctionType.Prefix,
|
||||
label: 'Functions',
|
||||
items: BOOLEAN_FUNCTIONS,
|
||||
},
|
||||
@ -295,7 +309,7 @@ export class CloudWatchLanguageProvider extends LanguageProvider {
|
||||
return {
|
||||
suggestions: [
|
||||
{
|
||||
prefixMatch: true,
|
||||
searchFunctionType: SearchFunctionType.Prefix,
|
||||
label: 'Functions',
|
||||
items: NUMERIC_OPERATORS.concat(BOOLEAN_FUNCTIONS),
|
||||
},
|
||||
|
@ -29,13 +29,13 @@ const NS_IN_MS = 1000000;
|
||||
// When changing RATE_RANGES, check if Prometheus/PromQL ranges should be changed too
|
||||
// @see public/app/plugins/datasource/prometheus/promql.ts
|
||||
const RATE_RANGES: CompletionItem[] = [
|
||||
{ label: '$__interval', sortText: '$__interval' },
|
||||
{ label: '1m', sortText: '00:01:00' },
|
||||
{ label: '5m', sortText: '00:05:00' },
|
||||
{ label: '10m', sortText: '00:10:00' },
|
||||
{ label: '30m', sortText: '00:30:00' },
|
||||
{ label: '1h', sortText: '01:00:00' },
|
||||
{ label: '1d', sortText: '24:00:00' },
|
||||
{ label: '$__interval', sortValue: '$__interval' },
|
||||
{ label: '1m', sortValue: '00:01:00' },
|
||||
{ label: '5m', sortValue: '00:05:00' },
|
||||
{ label: '10m', sortValue: '00:10:00' },
|
||||
{ label: '30m', sortValue: '00:30:00' },
|
||||
{ label: '1h', sortValue: '01:00:00' },
|
||||
{ label: '1d', sortValue: '24:00:00' },
|
||||
];
|
||||
|
||||
export const LABEL_REFRESH_INTERVAL = 1000 * 30; // 30sec
|
||||
|
@ -5,6 +5,7 @@ import { PrometheusDatasource } from './datasource';
|
||||
import { HistoryItem } from '@grafana/data';
|
||||
import { PromQuery } from './types';
|
||||
import Mock = jest.Mock;
|
||||
import { SearchFunctionType } from '@grafana/ui';
|
||||
|
||||
describe('Language completion provider', () => {
|
||||
const datasource: PrometheusDatasource = ({
|
||||
@ -123,14 +124,14 @@ describe('Language completion provider', () => {
|
||||
expect(result.suggestions).toMatchObject([
|
||||
{
|
||||
items: [
|
||||
{ label: '$__interval', sortText: '$__interval' }, // TODO: figure out why this row and sortText is needed
|
||||
{ label: '$__rate_interval', sortText: '$__rate_interval' },
|
||||
{ label: '1m', sortText: '00:01:00' },
|
||||
{ label: '5m', sortText: '00:05:00' },
|
||||
{ label: '10m', sortText: '00:10:00' },
|
||||
{ label: '30m', sortText: '00:30:00' },
|
||||
{ label: '1h', sortText: '01:00:00' },
|
||||
{ label: '1d', sortText: '24:00:00' },
|
||||
{ label: '$__interval', sortValue: '$__interval' }, // TODO: figure out why this row and sortValue is needed
|
||||
{ label: '$__rate_interval', sortValue: '$__rate_interval' },
|
||||
{ label: '1m', sortValue: '00:01:00' },
|
||||
{ label: '5m', sortValue: '00:05:00' },
|
||||
{ label: '10m', sortValue: '00:10:00' },
|
||||
{ label: '30m', sortValue: '00:30:00' },
|
||||
{ label: '1h', sortValue: '01:00:00' },
|
||||
{ label: '1d', sortValue: '24:00:00' },
|
||||
],
|
||||
label: 'Range vector',
|
||||
},
|
||||
@ -236,7 +237,13 @@ describe('Language completion provider', () => {
|
||||
value: valueWithSelection,
|
||||
});
|
||||
expect(result.context).toBe('context-labels');
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
|
||||
expect(result.suggestions).toEqual([
|
||||
{
|
||||
items: [{ label: 'job' }, { label: 'instance' }],
|
||||
label: 'Labels',
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns label suggestions on label context and metric', async () => {
|
||||
@ -255,7 +262,9 @@ describe('Language completion provider', () => {
|
||||
value: valueWithSelection,
|
||||
});
|
||||
expect(result.context).toBe('context-labels');
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||
expect(result.suggestions).toEqual([
|
||||
{ items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns label suggestions on label context but leaves out labels that already exist', async () => {
|
||||
@ -286,7 +295,9 @@ describe('Language completion provider', () => {
|
||||
value: valueWithSelection,
|
||||
});
|
||||
expect(result.context).toBe('context-labels');
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||
expect(result.suggestions).toEqual([
|
||||
{ items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns label value suggestions inside a label value context after a negated matching operator', async () => {
|
||||
@ -311,6 +322,7 @@ describe('Language completion provider', () => {
|
||||
{
|
||||
items: [{ label: 'value1' }, { label: 'value2' }],
|
||||
label: 'Label values for "job"',
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -346,7 +358,9 @@ describe('Language completion provider', () => {
|
||||
value: valueWithSelection,
|
||||
});
|
||||
expect(result.context).toBe('context-label-values');
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
|
||||
expect(result.suggestions).toEqual([
|
||||
{ items: [{ label: 'baz' }], label: 'Label values for "bar"', searchFunctionType: SearchFunctionType.Fuzzy },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns label suggestions on aggregation context and metric w/ selector', async () => {
|
||||
@ -364,7 +378,9 @@ describe('Language completion provider', () => {
|
||||
value: valueWithSelection,
|
||||
});
|
||||
expect(result.context).toBe('context-aggregation');
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||
expect(result.suggestions).toEqual([
|
||||
{ items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns label suggestions on aggregation context and metric w/o selector', async () => {
|
||||
@ -382,7 +398,9 @@ describe('Language completion provider', () => {
|
||||
value: valueWithSelection,
|
||||
});
|
||||
expect(result.context).toBe('context-aggregation');
|
||||
expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
|
||||
expect(result.suggestions).toEqual([
|
||||
{ items: [{ label: 'bar' }], label: 'Labels', searchFunctionType: SearchFunctionType.Fuzzy },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns label suggestions inside a multi-line aggregation context', async () => {
|
||||
@ -406,6 +424,7 @@ describe('Language completion provider', () => {
|
||||
{
|
||||
items: [{ label: 'bar' }],
|
||||
label: 'Labels',
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -429,6 +448,7 @@ describe('Language completion provider', () => {
|
||||
{
|
||||
items: [{ label: 'bar' }],
|
||||
label: 'Labels',
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -452,6 +472,7 @@ describe('Language completion provider', () => {
|
||||
{
|
||||
items: [{ label: 'bar' }],
|
||||
label: 'Labels',
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -490,6 +511,7 @@ describe('Language completion provider', () => {
|
||||
{
|
||||
items: [{ label: 'bar' }],
|
||||
label: 'Labels',
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
@ -3,16 +3,16 @@ import LRU from 'lru-cache';
|
||||
import { Value } from 'slate';
|
||||
|
||||
import { dateTime, HistoryItem, LanguageProvider } from '@grafana/data';
|
||||
import { CompletionItem, CompletionItemGroup, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
|
||||
import { CompletionItem, CompletionItemGroup, SearchFunctionType, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
|
||||
|
||||
import {
|
||||
addLimitInfo,
|
||||
fixSummariesMetadata,
|
||||
limitSuggestions,
|
||||
parseSelector,
|
||||
processHistogramLabels,
|
||||
processLabels,
|
||||
roundSecToMin,
|
||||
addLimitInfo,
|
||||
limitSuggestions,
|
||||
} from './language_utils';
|
||||
import PromqlSyntax, { FUNCTIONS, RATE_RANGES } from './promql';
|
||||
|
||||
@ -201,7 +201,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
|
||||
getEmptyCompletionItems = (context: { history: Array<HistoryItem<PromQuery>> }): TypeaheadOutput => {
|
||||
const { history } = context;
|
||||
const suggestions = [];
|
||||
const suggestions: CompletionItemGroup[] = [];
|
||||
|
||||
if (history && history.length) {
|
||||
const historyItems = _.chain(history)
|
||||
@ -214,7 +214,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
.value();
|
||||
|
||||
suggestions.push({
|
||||
prefixMatch: true,
|
||||
searchFunctionType: SearchFunctionType.Prefix,
|
||||
skipSort: true,
|
||||
label: 'History',
|
||||
items: historyItems,
|
||||
@ -226,10 +226,10 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
|
||||
getTermCompletionItems = (): TypeaheadOutput => {
|
||||
const { metrics, metricsMetadata } = this;
|
||||
const suggestions = [];
|
||||
const suggestions: CompletionItemGroup[] = [];
|
||||
|
||||
suggestions.push({
|
||||
prefixMatch: true,
|
||||
searchFunctionType: SearchFunctionType.Prefix,
|
||||
label: 'Functions',
|
||||
items: FUNCTIONS.map(setFunctionKind),
|
||||
});
|
||||
@ -239,6 +239,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
suggestions.push({
|
||||
label: `Metrics${limitInfo}`,
|
||||
items: limitSuggestions(metrics).map((m) => addMetricsMetadata(m, metricsMetadata)),
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
});
|
||||
}
|
||||
|
||||
@ -313,6 +314,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
suggestions.push({
|
||||
label: `Labels${limitInfo}`,
|
||||
items: Object.keys(labelValues).map(wrapLabel),
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@ -379,6 +381,7 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
suggestions.push({
|
||||
label: `Label values for "${labelKey}"${limitInfo}`,
|
||||
items: labelValues[labelKey].map(wrapLabel),
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@ -391,7 +394,11 @@ export default class PromQlLanguageProvider extends LanguageProvider {
|
||||
context = 'context-labels';
|
||||
const newItems = possibleKeys.map((key) => ({ label: key }));
|
||||
const limitInfo = addLimitInfo(newItems);
|
||||
const newSuggestion: CompletionItemGroup = { label: `Labels${limitInfo}`, items: newItems };
|
||||
const newSuggestion: CompletionItemGroup = {
|
||||
label: `Labels${limitInfo}`,
|
||||
items: newItems,
|
||||
searchFunctionType: SearchFunctionType.Fuzzy,
|
||||
};
|
||||
suggestions.push(newSuggestion);
|
||||
}
|
||||
}
|
||||
|
@ -4,14 +4,14 @@ import { CompletionItem } from '@grafana/ui';
|
||||
// When changing RATE_RANGES, check if Loki/LogQL ranges should be changed too
|
||||
// @see public/app/plugins/datasource/loki/language_provider.ts
|
||||
export const RATE_RANGES: CompletionItem[] = [
|
||||
{ label: '$__interval', sortText: '$__interval' },
|
||||
{ label: '$__rate_interval', sortText: '$__rate_interval' },
|
||||
{ label: '1m', sortText: '00:01:00' },
|
||||
{ label: '5m', sortText: '00:05:00' },
|
||||
{ label: '10m', sortText: '00:10:00' },
|
||||
{ label: '30m', sortText: '00:30:00' },
|
||||
{ label: '1h', sortText: '01:00:00' },
|
||||
{ label: '1d', sortText: '24:00:00' },
|
||||
{ label: '$__interval', sortValue: '$__interval' },
|
||||
{ label: '$__rate_interval', sortValue: '$__rate_interval' },
|
||||
{ label: '1m', sortValue: '00:01:00' },
|
||||
{ label: '5m', sortValue: '00:05:00' },
|
||||
{ label: '10m', sortValue: '00:10:00' },
|
||||
{ label: '30m', sortValue: '00:30:00' },
|
||||
{ label: '1h', sortValue: '01:00:00' },
|
||||
{ label: '1d', sortValue: '24:00:00' },
|
||||
];
|
||||
|
||||
export const OPERATORS = ['by', 'group_left', 'group_right', 'ignoring', 'on', 'offset', 'without'];
|
||||
|
Loading…
Reference in New Issue
Block a user